diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..21bfe17d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto +*.yaml text eol=lf +*.yml text eol=lf +*.md text eol=lf diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index f7bdd844..753b6e9e 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -2,6 +2,8 @@ name: Publish Docker Image on: push: + branches: + - main tags: - "v*" workflow_dispatch: @@ -41,9 +43,10 @@ jobs: with: images: ghcr.io/${{ github.repository_owner }}/${{ env.IMAGE_NAME }} tags: | + type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} type=raw,value=latest,enable=${{ startsWith(github.ref, 'refs/tags/v') }} type=ref,event=tag - type=sha + type=sha,prefix=sha- type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} diff --git a/9router-test b/9router-test new file mode 160000 index 00000000..7ad538bc --- /dev/null +++ b/9router-test @@ -0,0 +1 @@ +Subproject commit 7ad538bcf248a563e78f984bf64e66286058917f diff --git a/Dockerfile.lite b/Dockerfile.lite new file mode 100644 index 00000000..12802634 --- /dev/null +++ b/Dockerfile.lite @@ -0,0 +1,25 @@ +FROM ghcr.io/astral-sh/uv:python3.13-bookworm-slim + +LABEL org.opencontainers.image.description="chatgpt2api Lite - OpenAI-compatible proxy for ChatGPT Web API (no Web UI)" + +WORKDIR /app + +# Install system dependencies (only openssl for curl-cffi) +RUN apt-get update && apt-get install -y --no-install-recommends \ + openssl \ + && rm -rf /var/lib/apt/lists/* + +# Install Python dependencies +COPY pyproject.toml uv.lock ./ +RUN uv sync --frozen --no-dev --no-install-project + +# Copy application code +COPY main_lite.py ./main.py +COPY VERSION ./ +COPY api ./api +COPY services ./services +COPY utils ./utils + +EXPOSE 80 + +CMD ["uv", "run", "uvicorn", "main:app", "--host", "0.0.0.0", "--port", "80", "--access-log"] diff --git a/README.md b/README.md index ac59ba33..d764c0f3 100644 --- a/README.md +++ b/README.md @@ -1,320 +1,304 @@ -

ChatGPT2API

+# chatgpt2api +OpenAI-compatible API gateway tích hợp **ChatGPT Web, Codex OAuth, OpenCode free, Gemini, DALL-E, SD WebUI**. Dùng làm AI agent backend cho Home Assistant. -

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

+## Mục lục -> [!WARNING] -> 免责声明: -> -> 本项目涉及对 ChatGPT 官网文本生成、图片生成与图片编辑等相关接口的逆向研究,仅供个人学习、技术研究与非商业性技术交流使用。 -> -> - 严禁将本项目用于任何商业用途、盈利性使用、批量操作、自动化滥用或规模化调用。 -> - 严禁将本项目用于破坏市场秩序、恶意竞争、套利倒卖、二次售卖相关服务,以及任何违反 OpenAI 服务条款或当地法律法规的行为。 -> - 严禁将本项目用于生成、传播或协助生成违法、暴力、色情、未成年人相关内容,或用于诈骗、欺诈、骚扰等非法或不当用途。 -> - 使用者应自行承担全部风险,包括但不限于账号被限制、临时封禁或永久封禁以及因违规使用等所导致的法律责任。 -> - 使用本项目即视为你已充分理解并同意本免责声明全部内容;如因滥用、违规或违法使用造成任何后果,均由使用者自行承担。 +- [Cài đặt qua Home Assistant Addon](#cai-dat-qua-home-assistant-addon) +- [Cài đặt qua Docker](#cai-dat-qua-docker) +- [Cài đặt qua Docker Compose / Portainer](#cai-dat-qua-docker-compose--portainer) +- [Cài đặt trực tiếp (source)](#cai-dat-truc-tiep-source) +- [Cấu hình Home Assistant](#cau-hinh-home-assistant) +- [Thêm tài khoản](#them-tai-khoan) +- [Model](#model) +- [Tìm kiếm (Search)](#tim-kiem-search) +- [API Endpoints](#api-endpoints) +- [Troubleshooting](#troubleshooting) -> [!IMPORTANT] -> 本项目基于对 ChatGPT 官网相关能力的逆向研究实现,存在账号受限、临时封禁或永久封禁的风险。请勿使用你自己的重要账号、常用账号或高价值账号进行测试。 +--- -> [!CAUTION] -> 旧版本存在已知漏洞,请尽快升级到最新版本。公网部署时请尽量不要放置敏感信息,并自行做好访问控制与隔离。 +## Cài đặt qua Home Assistant Addon -## 快速开始 +[![Add Repository](https://my.home-assistant.io/badges/supervisor_add_addon_repository.svg)](https://my.home-assistant.io/redirect/supervisor_add_addon_repository/?repository_url=https%3A%2F%2Fgithub.com%2FTriTue2011%2Fhas-addons) -已发布镜像支持 `linux/amd64` 与 `linux/arm64`,在 x86 服务器和 Apple Silicon / ARM Linux 设备上都会自动拉取匹配架构的版本。 +**Bước 1:** Vào **Settings → Add-ons → Add-on Store** -### Docker 运行 +**Bước 2:** Nhấn **⋮** (góc phải trên) → **Repositories** -```bash -git clone git@github.com:basketikun/chatgpt2api.git -cd chatgpt2api -docker compose up -d -``` +**Bước 3:** Thêm URL: `https://github.com/TriTue2011/has-addons` → **Add** -启动前请先在 `config.json` 中设置 `auth-key`,也可以在 `docker-compose.yml` 中通过 `CHATGPT2API_AUTH_KEY` 覆盖。 +**Bước 4:** Tìm **chatgpt2api** trong store → **Install** -- Web 面板:`http://localhost:3000` -- API 地址:`http://localhost:3000/v1` -- 数据目录:`./data` +**Bước 5:** Tab **Configuration** → sửa `auth_key` (mặc định: `sk-chatgpt2api`) → **Save** -### 本地开发 +**Bước 6:** **Start** → mở Web UI tại `http://HA_IP:3030` -启动后端: +**Bước 7:** Đăng nhập bằng `auth_key` đã đặt -```bash -git clone git@github.com:basketikun/chatgpt2api.git -cd chatgpt2api -uv sync -uv run main.py -``` +Sau đó vào [Cấu hình Home Assistant](#cau-hinh-home-assistant). + +--- -启动前端: +## Cài đặt qua Docker ```bash -cd chatgpt2api/web -bun install -bun run dev +docker run -d \ + --name chatgpt2api \ + --restart unless-stopped \ + -p 3030:80 \ + -v chatgpt2api_data:/app/data \ + -e CHATGPT2API_AUTH_KEY=your_secret_key_here \ + ghcr.io/tritue2011/chatgpt2api:latest ``` -### 存储后端配置 +Sau khi chạy: +- Web UI: `http://IP:3030` +- API: `http://IP:3030/v1/chat/completions` +- Đăng nhập Web UI bằng `your_secret_key_here` -支持通过环境变量 `STORAGE_BACKEND` 切换存储方式: +> **Quan trọng**: Volume `chatgpt2api_data` lưu TOÀN Bộ dữ liệu: accounts, API keys (Gemini, NVIDIA, DeepSeek...), custom providers, model settings, combos, ảnh, backup. **Không được xóa volume này** nếu không muốn mất hết cài đặt. -- `json` - 本地 JSON 文件(默认) -- `sqlite` - 本地 SQLite 数据库 -- `postgres` - 外部 PostgreSQL(需配置 `DATABASE_URL`) -- `git` - Git 私有仓库(需配置 `GIT_REPO_URL` 和 `GIT_TOKEN`) +--- -示例:使用 PostgreSQL +## Cài đặt qua Docker Compose / Portainer ```yaml -environment: - - STORAGE_BACKEND=postgres - - DATABASE_URL=postgresql://user:password@host:5432/dbname +services: + chatgpt2api: + image: ghcr.io/tritue2011/chatgpt2api:latest + container_name: chatgpt2api + restart: unless-stopped + ports: + - "3030:80" + volumes: + # [QUAN TRỌNG] Bind mount → thư mục thật trên host, không bao giờ mất khi build lại + - ./chatgpt2api-data:/app/data + environment: + # [BẮT BUỘC] Đổi thành key bảo mật của bạn + CHATGPT2API_AUTH_KEY: your_secret_key_here + STORAGE_BACKEND: json + +# Không cần khai báo volumes ở cuối nếu dùng bind mount ``` -## 功能 +**Portainer:** Stacks → Add Stack → Web Editor → paste nội dung trên → Deploy. + +### Cập nhật lên phiên bản mới (giữ nguyên dữ liệu) + +```bash +docker compose pull +docker compose up -d +``` + +Dữ liệu trong `./chatgpt2api-data/` (thư mục thật trên host) **không bao giờ mất** khi pull image mới. + +--- + +## Cài đặt trực tiếp (source) -### API 兼容能力 +Yêu cầu: Python 3.12+, Node.js 20+, Git -- 兼容 `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 两份生图额度 +```bash +git clone https://github.com/TriTue2011/chatgpt2api +cd chatgpt2api -### 在线画图功能 +# Cài Python dependencies +pip install uv +uv sync -- 内置在线画图工作台,支持生成、图片编辑与多图组图编辑 -- 支持 `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 +# Build web UI +cd web && npm install && npm run build && cd .. -### 号池管理功能 +# Chạy +cp .env.example .env +# Sửa CHATGPT2API_AUTH_KEY trong .env +uv run uvicorn main:app --host 0.0.0.0 --port 3030 +``` -- 自动刷新账号邮箱、类型、额度和恢复时间 -- 轮询可用账号执行图片生成与图片编辑 -- 遇到 Token 失效类错误时自动剔除无效 Token -- 定时检查限流账号并自动刷新 -- 支持网页端配置全局 HTTP / HTTPS / SOCKS5 / SOCKS5H 代理 -- 支持搜索、筛选、批量刷新、导出、手动编辑和清理账号 -- 支持四种导入方式:本地 CPA JSON 文件导入、远程 CPA 服务器导入、`sub2api` 服务器导入、`access_token` 导入 -- 支持在设置页配置 `sub2api` 服务器,筛选并批量导入其中的 OpenAI OAuth 账号 +--- -### 实验性 / 规划中 +## Cấu hình Home Assistant -- `/v1/complete` 文本补全与流式输出已实现,但仍在测试,目前会出现对话重复的问题,请谨慎测试使用 -- 详细状态说明见:[功能清单](./docs/feature-status.en.md) +Sau khi chatgpt2api đã chạy, cấu hình HA để dùng nó làm conversation agent. -## Screenshots +### Dùng OpenAI Conversation (có sẵn trong HA) -文生图界面: +**Settings → Devices & Services → Add Integration → OpenAI Conversation:** -![image](assets/image.png) +| Field | Value | +|-------|-------| +| Base URL | `http://localhost:3030/v1` | +| API Key | Key đã đặt ở bước trên | +| Model | `ha-agent` | -编辑图: +### Dùng với hass_local_openai_llm -![image](assets/image_edit.png) +```yaml +# configuration.yaml +openai_llm: + - name: chatgpt2api + base_url: http://localhost:3030 + api_key: your_secret_key_here + model: ha-agent +``` -Cherry Studio 中使用,支持作为绘图接口接入: +### Voice Pipeline -![image](assets/chery_studio.png) +**Settings → Voice Assistants →** chọn pipeline → **Conversation Agent** → chọn agent đã config. -号池管理: +--- -![image](assets/account_pool.png) +## Thêm tài khoản -New Api 接入: +### Token ChatGPT Web (chat + tạo ảnh) -![image](assets/new_api.png) +1. Mở browser, đăng nhập https://chatgpt.com +2. Vào https://chatgpt.com/api/auth/session +3. Copy giá trị `accessToken` +4. Web UI → **Tài khoản → Nhập tài khoản → Nhập Access Token** → paste +5. Token JWT (bắt đầu `eyJ`) tự động dùng được cho cả chat (`cx/auto`) và tạo ảnh (`gpt-image-2`) -## API +### Token Codex OAuth từ 9router (chat không giới hạn) -所有 AI 接口都需要请求头: +1. Web UI → **Sao lưu** → kéo thả file backup `.json` từ 9router +2. 10 token tự động thêm vào pool +3. Dùng model `cx/auto` — không giới hạn 24KB, native tool calling -```http -Authorization: Bearer -``` +### Gemini API Key (chat + search) -
-GET /v1/models -
+1. Vào https://aistudio.google.com/apikey → tạo API key (miễn phí 15 RPM) +2. Web UI → **Cài đặt → Gemini AI Studio** +3. Dán key (mỗi dòng 1 key nếu có nhiều) +4. Chọn model: `gemini-2.5-flash` (ổn định) hoặc `gemini-3-flash-preview` (mới nhất) +5. **Lưu** +6. Dùng model `gemini_free/auto` cho chat + tự động search Google -返回当前暴露的图片模型列表。 +--- -```bash -curl http://localhost:8000/v1/models \ - -H "Authorization: Bearer " -``` +## Model -
-说明 -
+### Model cho chat -| 字段 | 说明 | -|:-----|:-----------------------------------------------------------------------------------------------------------| -| 返回模型 | `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 等上游或客户端 | +| Model | Provider | Cần token | Tool Call | Giới hạn | +|-------|----------|-----------|-----------|----------| +| `ha-agent` | **Combo tự động** | Auto | ✅ Native | Tự fallback | +| `oc/auto` | OpenCode | **Không** | ✅ Text→Native | Không | +| `cx/auto` | Codex OAuth | 9router backup | ✅ Native | Không | +| `gemini_free/auto` | Gemini | API key | ✅ Native | 15 RPM/key | +| `chatgpt/auto` | ChatGPT Web | Session cookie | ✅ Native | 24KB (free) | -
-
-
+### Model cho tạo ảnh -
-POST /v1/images/generations -
+| Model | Provider | Cần | +|-------|----------|-----| +| `gpt-image-2` | DALL-E (chatgpt.com) | Token ChatGPT | +| `sdwebui/sd-v1.5` | Stable Diffusion local | GPU + SD WebUI | +| `huggingface/flux-schnell` | FLUX qua HuggingFace | API key (free tier) | -OpenAI 兼容图片生成接口,用于文生图。 +### Combo Models -```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" - }' +Combo tự động thử từng model đến khi có kết quả. Cấu hình trong Web UI → **Cài đặt → Providers Card**: + +```json +{ + "ha-agent": ["gemini_free/auto", "cx/auto", "oc/auto"], + "ha-agent-image": ["chatgpt/gpt-image-2", "sdwebui/stable-diffusion"] +} ``` -
-字段说明 -
+--- -| 字段 | 说明 | -|:------------------|:---------------------------------------------------| -| `model` | 图片模型,当前可用值以 `/v1/models` 返回结果为准,推荐使用 `gpt-image-2` | -| `prompt` | 图片生成提示词 | -| `n` | 生成数量,当前后端限制为 `1-4` | -| `response_format` | 当前请求模型中包含该字段,默认值为 `b64_json` | +## Tìm kiếm (Search) -
-
-
+Khi dùng model không có search built-in (`cx/auto`, `oc/auto`, `gemini_free/auto`), hệ thống tự động: -
-POST /v1/images/edits -
+1. Phát hiện câu hỏi cần tìm kiếm (regex tiếng Việt) +2. Gọi Google Search qua Gemini API (dùng key từ Cài đặt Gemini) +3. Inject kết quả vào prompt +4. Model trả lời dựa trên dữ liệu thực -OpenAI 兼容图片编辑接口,用于上传图片并生成编辑结果。 +**Cấu hình:** Web UI → **Tìm kiếm** → bật + chọn backend → Lưu. -```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" -``` +Backend hỗ trợ: +- **Gemini** (Google Search grounding, miễn phí 15 RPM) +- **SearXNG** (tự host, không giới hạn) +- **Serper.dev** (Google Search API, 2.5K req/tháng free) +- **Brave Search** (2K req/tháng free) -
-字段说明 -
+--- -| 字段 | 说明 | -|:---------|:------------------------------------| -| `model` | 图片模型, `gpt-image-2` | -| `prompt` | 图片编辑提示词 | -| `n` | 生成数量,当前后端限制为 `1-4` | -| `image` | 需要编辑的图片文件,使用 multipart/form-data 上传 | +## API Endpoints -
-
-
+| Method | Path | Auth | Mô tả | +|--------|------|------|-------| +| POST | `/v1/chat/completions` | API key | Chat (OpenAI format) | +| POST | `/v1/images/generations` | API key | Tạo ảnh | +| POST | `/v1/messages` | API key | Chat (Anthropic format) | +| GET | `/v1/models` | API key | Danh sách model | +| GET | `/api/accounts` | Admin | Danh sách tài khoản | +| POST | `/api/accounts` | Admin | Thêm tài khoản | +| POST | `/api/v1/import-9router-upload` | Admin | Import backup 9router | +| POST | `/api/v1/backup` | Admin | Tạo backup toàn bộ | +| POST | `/api/v1/restore` | Admin | Phục hồi từ backup | +| GET | `/api/v1/health` | Admin | Trạng thái hệ thống | -
-POST /v1/chat/completions -
+--- -面向图片场景的 Chat Completions 兼容接口,不是完整通用聊天代理。 +## Troubleshooting -```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 - }' -``` +### "Mất API key Gemini/NVIDIA/DeepSeek sau khi cập nhật" -
-字段说明 -
+**Nguyên nhân**: Volume name không cố định, hoặc dùng `docker compose down -v`. -| 字段 | 说明 | -|:-----------|:------------------| -| `model` | 图片模型,默认按图片生成场景处理 | -| `messages` | 消息数组,需要是图片相关请求内容 | -| `n` | 生成数量,按当前实现解析为图片数量 | -| `stream` | 已实现,但仍在测试 | +**Cách fix**: +1. Đảm bảo `docker-compose.yml` có volume với `name:` cố định: + ```yaml + volumes: + chatgpt2api_data: + name: chatgpt2api_data + ``` +2. Khi cập nhật, chỉ dùng: `docker compose pull && docker compose up -d` +3. **Không dùng** `docker compose down -v` (cờ `-v` xóa volume) -
-
-
+### "Error talking to API" / HTTP 413 -
-POST /v1/responses -
+Payload vượt 24KB (giới hạn ChatGPT free account). Giải pháp: +- Đổi model sang `oc/auto`, `cx/auto`, hoặc `gemini_free/auto` +- Giảm **Max Message History** trong HA integration xuống 5 +- Tắt **Content Injection** (date/time) trong HA integration -面向图片生成工具调用的 Responses API 兼容接口,不是完整通用 Responses API 代理。 +### Token hết quota (429) -```bash -curl http://localhost:8000/v1/responses \ - -H "Content-Type: application/json" \ - -H "Authorization: Bearer " \ - -d '{ - "model": "gpt-5", - "input": "生成一张未来感城市天际线图片", - "tools": [ - { - "type": "image_generation" - } - ] - }' -``` +- **ChatGPT/Codex**: Hệ thống tự động round-robin token khác trong pool +- **Gemini**: Thêm nhiều API key (mỗi dòng 1 key trong Cài đặt Gemini) +- **OpenCode**: Không giới hạn, luôn hoạt động -
-字段说明 -
+### Không kết nối được từ HA -| 字段 | 说明 | -|:---------|:------------------------------| -| `model` | 响应中会回显该模型字段,但图片生成当前仍走图片生成兼容逻辑 | -| `input` | 输入内容,需要能解析出图片生成提示词 | -| `tools` | 必须包含 `image_generation` 工具请求 | -| `stream` | 已实现,但仍在测试 | +- Base URL phải có `/v1` ở cuối: `http://IP:3030/v1` +- Kiểm tra `docker logs chatgpt2api` xem có request đến không +- Nếu HA và chatgpt2api khác máy, dùng IP thay vì localhost -
-
-
+### Addon không hiện trong HA Add-on Store -## 社区支持 +- **Ctrl+F5** refresh cứng +- **Add-on Store → ⋮ → Check for updates** +- Xóa repository → thêm lại +- Kiểm tra **Settings → System → Logs → Supervisor** -学 AI , 上 L 站:[LinuxDO](https://linux.do) +### Search không có kết quả -## Contributors +- Kiểm tra Gemini API key trong **Cài đặt → Gemini** +- Key phải có dạng `AIza...` +- Model preview có thể chưa hỗ trợ search grounding → dùng `gemini-2.5-flash` -感谢所有为本项目做出贡献的开发者: +## Credits - - Contributors - +- [9router](https://github.com/TriTue2011/9router) — OAuth flow, multi-provider architecture +- [hass_local_openai_llm](https://github.com/skye-harris/hass_local_openai_llm) — HA integration +- OpenCode.ai — Free LLM API +- Google Gemini — Search grounding + free tier -## Star History +## License -[![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) +MIT diff --git a/README.vi.md b/README.vi.md new file mode 100644 index 00000000..7af4bb85 --- /dev/null +++ b/README.vi.md @@ -0,0 +1,84 @@ +# Hướng Dẫn Cài Đặt & Sử Dụng ChatGPT2API Cho Home Assistant + +ChatGPT2API là dự án cho phép biến tài khoản ChatGPT Web của bạn thành một API chuẩn OpenAI. Phiên bản này đã được tùy chỉnh đặc biệt để lọc sạch các ký tự định dạng (Markdown, dấu gạch ngang, tiêu đề...) nhằm tương thích hoàn hảo 100% với hệ thống giọng nói (Text-To-Speech) của loa thông minh như Phicomm R1 qua Home Assistant. + +--- + +## 1. Cài Đặt Hệ Thống (Bằng Docker) + +Yêu cầu máy chủ (Ubuntu/Debian, Raspberry Pi, Debian trên Home Assistant...) đã cài đặt sẵn `docker` và `docker-compose`. + +```bash +# Clone source code +git clone https://github.com/TriTue2011/chatgpt2api.git +cd chatgpt2api + +# Khởi chạy hệ thống (tự động build) +docker-compose up -d --build +``` + +Sau khi chạy xong, hệ thống sẽ mở 2 cổng: +- Web Quản lý: `http://[IP_MÁY_CHỦ]:3000` +- API Endpoint: `http://[IP_MÁY_CHỦ]:3000/v1` + +--- + +## 2. Cách Lấy Access Token / Session Của ChatGPT + +Để hệ thống hoạt động, bạn cần cấp cho nó tài khoản ChatGPT. Cách an toàn và đơn giản nhất là lấy `access_token` từ trình duyệt: + +1. Mở trình duyệt ẩn danh (Incognito) và đăng nhập vào trang [chatgpt.com](https://chatgpt.com). +2. Sau khi đăng nhập thành công, mở tab mới và dán đường link này vào thanh địa chỉ: + `https://chatgpt.com/api/auth/session` +3. Trình duyệt sẽ hiển thị một đoạn mã JSON. Bạn tìm chuỗi bắt đầu bằng `eyJhbG...` nằm sau chữ `"accessToken":`. +4. Copy toàn bộ chuỗi Access Token đó (nó rất dài). + +*Lưu ý: Không đăng xuất (Log out) ChatGPT trên trình duyệt đó, nếu không token sẽ bị hủy.* + +--- + +## 3. Cách Gán Tài Khoản Vào ChatGPT2API + +1. Truy cập vào Web UI quản lý của hệ thống: `http://[IP_MÁY_CHỦ]:3000` +2. Đăng nhập bằng Auth Key. Mặc định trong file `config.json` là: `chatgpt2api` (bạn có thể đổi tùy ý). +3. Tại giao diện chính, chuyển sang tab **Tài khoản (Account Pool)**. +4. Bấm vào nút **Nhập Access Token (Import Access Token)**. +5. Dán đoạn Access Token bạn vừa copy ở Bước 2 vào ô trống, mỗi token nằm trên 1 dòng nếu bạn có nhiều tài khoản. +6. Bấm **Xác nhận (Confirm)**. Hệ thống sẽ tự động kiểm tra xem tài khoản là Plus hay Thường, và đưa vào trạng thái Hoạt động (Active). + +--- + +## 4. Tích Hợp Vào Home Assistant + +Để sử dụng ChatGPT2API làm não bộ cho trợ lý ảo của Home Assistant, bạn cần cấu hình tích hợp **OpenAI Conversation** (Hoặc các bản mod tương tự như Local OpenAI). + +1. Vào Home Assistant -> **Cài đặt (Settings)** -> **Thiết bị & Dịch vụ (Devices & Services)**. +2. Thêm tích hợp **OpenAI Conversation**. +3. Điền các thông số sau: + - **API Key**: `chatgpt2api` (Phải khớp với `auth-key` trong config.json) + - **Base URL**: `http://[IP_MÁY_CHỦ_CỦA_BẠN]:3000/v1` +4. Bấm Xác nhận. +5. Sau khi thêm xong, cấu hình Integration và chọn mô hình (Model) là `auto` hoặc `gpt-4o`. + +--- + +## 5. Tối Ưu Hóa Cho Loa Thông Minh (TTS) + +Tuy bản thân ChatGPT2API đã tự động lọc sạch các mã Markdown (dấu `#`, `*`, `- `) khi trả về, nhưng để câu văn tự nhiên nhất khi phát qua loa, bạn cần thêm System Prompt vào Home Assistant. + +Vào **Cài đặt -> Trợ lý giọng nói -> Chọn Trợ lý của bạn -> Phần Chỉ thị (Instructions)**, dán đoạn sau: + +> *"Bạn là trợ lý ảo nhà thông minh. Hãy trả lời cực kỳ ngắn gọn, tự nhiên và giống văn nói của con người để hệ thống TTS có thể đọc mượt mà. Tuyệt đối KHÔNG sử dụng các ký tự định dạng (như dấu sao *, dấu thăng #, gạch đầu dòng -). Không dùng danh sách liệt kê, hạn chế tối đa ngoặc đơn. Trả lời thẳng vào trọng tâm câu hỏi. QUAN TRỌNG: Ngay cả khi lấy dữ liệu từ Web Search, tuyệt đối không được dùng định dạng liệt kê."* + +--- + +## 6. Cập Nhật Code (Khi có bản sửa lỗi mới) + +Nếu có bản cập nhật mới trên Github, bạn chỉ cần thực hiện 3 lệnh sau để nâng cấp: + +```bash +cd /opt/chatgpt2api +docker-compose down +git pull origin main +docker-compose up -d --build +``` diff --git a/VERSION b/VERSION index a5ba9325..b966e81a 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -1.1.7 \ No newline at end of file +1.2.4 \ No newline at end of file diff --git a/api/accounts.py b/api/accounts.py index 897494cc..201387aa 100644 --- a/api/accounts.py +++ b/api/accounts.py @@ -146,6 +146,128 @@ async def get_accounts(authorization: str | None = Header(default=None)): require_admin(authorization) return {"items": account_service.list_accounts()} + @router.get("/api/v1/provider-tree") + async def get_provider_tree(authorization: str | None = Header(default=None)): + """Return hierarchical tree: Provider → Sub-type/API → Accounts/Status.""" + require_admin(authorization) + from services.config import config + from services.providers.custom_openai import get_custom_providers + import requests as req + + accounts = account_service.list_accounts() + providers_cfg = config.data.get("providers") or {} + custom_providers = get_custom_providers() + + tree: list[dict] = [] + + # ── ChatGPT branch ── + chatgpt_accounts = [a for a in accounts if str(a.get("type") or "").lower() not in ("", "custom")] + if chatgpt_accounts: + # Group by type + type_groups: dict[str, list] = {} + for acc in chatgpt_accounts: + acc_type = str(acc.get("type") or "free") + type_groups.setdefault(acc_type, []).append(acc) + groups = [] + for acc_type, accs in sorted(type_groups.items()): + groups.append({ + "key": acc_type, + "label": acc_type, + "count": len(accs), + "active": sum(1 for a in accs if a.get("status") == "active"), + "limited": sum(1 for a in accs if a.get("status") == "limited"), + "error": sum(1 for a in accs if a.get("status") in ("error", "disabled")), + "accounts": accs, + }) + tree.append({ + "provider": "ChatGPT", + "icon": "chatgpt", + "type": "accounts", + "groups": groups, + "total": len(chatgpt_accounts), + }) + + # ── Built-in providers branch (Gemini, NVIDIA, etc.) ── + builtin_list = [] + for p_id, p_cfg in providers_cfg.items(): + if not isinstance(p_cfg, dict) or not p_cfg.get("enabled", False): + continue + api_key = p_cfg.get("api_key") or "" + base_url = p_cfg.get("base_url") or "" + builtin_list.append({ + "id": p_id, + "name": p_cfg.get("name") or p_id, + "has_key": bool(api_key), + "key_preview": (api_key[:12] + "..." + api_key[-4:]) if len(api_key) > 16 else (api_key or "—"), + "base_url": base_url or "—", + "status": "configured", + }) + if builtin_list: + tree.append({ + "provider": "Providers", + "icon": "cpu", + "type": "providers", + "instances": builtin_list, + "total": len(builtin_list), + }) + + # ── Custom providers branch ── + custom_list = [] + for cp_id, cp_cfg in custom_providers.items(): + base_url = cp_cfg.get("base_url") or "" + port = base_url.split(":")[-1].rstrip("/") if ":" in base_url else "—" + # Quick health check + status = "unknown" + error_msg = None + models_count = 0 + if base_url: + try: + resp = req.get(f"{base_url}/health", timeout=4) + if resp.status_code == 200: + data = resp.json() + if data.get("ok"): + status = "available" + models_count = len(data.get("clients") or {}) + else: + status = "error" + error_msg = data.get("error", "unknown") + else: + # Try /v1/models + try: + resp2 = req.get(f"{base_url}/v1/models", timeout=4) + if resp2.status_code == 200: + data2 = resp2.json() + models_list = data2.get("data") or data2.get("models") or [] + status = "available" + models_count = len(models_list) + else: + status = f"http_{resp.status_code}" + except Exception: + status = f"http_{resp.status_code}" + except Exception as e: + status = "offline" + error_msg = str(e)[:60] + custom_list.append({ + "id": cp_id, + "name": cp_cfg.get("name") or cp_id, + "prefix": cp_cfg.get("prefix") or cp_id, + "base_url": base_url, + "port": port, + "status": status, + "models": models_count, + "error": error_msg, + }) + if custom_list: + tree.append({ + "provider": "Custom APIs", + "icon": "server", + "type": "custom", + "instances": custom_list, + "total": len(custom_list), + }) + + return {"tree": tree} + @router.post("/api/accounts") async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)): require_admin(authorization) @@ -153,14 +275,40 @@ async def create_accounts(body: AccountCreateRequest, authorization: str | None if not tokens: raise HTTPException(status_code=400, detail={"error": "tokens is required"}) result = account_service.add_accounts(tokens) - refresh_result = account_service.refresh_accounts(tokens) + # Skip refresh for JWT/OAuth tokens (they use different auth flow) + chatgpt_tokens = [t for t in tokens if not t.startswith("eyJ")] + if chatgpt_tokens: + refresh_result = account_service.refresh_accounts(chatgpt_tokens) + refreshed = refresh_result.get("refreshed", 0) + errors = refresh_result.get("errors", []) + else: + # JWT tokens: set default quota and mark as image-capable + for token in tokens: + account_service.update_account(token, { + "image_quota_unknown": True, + "quota": 10, + "status": "active", + }) + refreshed = len(tokens) + errors = [] return { **result, - "refreshed": refresh_result.get("refreshed", 0), - "errors": refresh_result.get("errors", []), - "items": refresh_result.get("items", result.get("items", [])), + "refreshed": refreshed, + "errors": errors, + "items": refresh_result.get("items", result.get("items", [])) if chatgpt_tokens else result.get("items", []), } + @router.post("/api/accounts/oauth") + async def create_oauth_accounts(body: dict, authorization: str | None = Header(default=None)): + """Add OAuth tokens (Codex from 9router) with custom type.""" + require_admin(authorization) + tokens = [str(t or "").strip() for t in (body.get("tokens") or []) if str(t or "").strip()] + if not tokens: + raise HTTPException(status_code=400, detail={"error": "tokens is required"}) + account_type = str(body.get("type") or "free,codex").strip() + result = account_service.add_accounts_with_type(tokens, account_type) + return {"items": result.get("items", []), "added": result.get("added", 0), "skipped": result.get("skipped", 0)} + @router.delete("/api/accounts") async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)): require_admin(authorization) diff --git a/api/ai.py b/api/ai.py index 34d36e08..602167d0 100644 --- a/api/ai.py +++ b/api/ai.py @@ -58,7 +58,7 @@ 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)) + call.log("Gọi thất bại", status="failed", error=str(exc.detail)) raise @@ -66,10 +66,11 @@ def create_router() -> APIRouter: router = APIRouter() @router.get("/v1/models") - async def list_models(authorization: str | None = Header(default=None)): + async def list_models(request: Request, authorization: str | None = Header(default=None)): require_identity(authorization) + force_refresh = request.query_params.get("refresh", "").lower() == "true" try: - return await run_in_threadpool(openai_v1_models.list_models) + return await run_in_threadpool(openai_v1_models.list_models, force_refresh, True) except Exception as exc: raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc @@ -131,7 +132,7 @@ async def create_chat_completion(body: ChatCompletionRequest, authorization: str 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) + call = LoggedCall(identity, "/v1/chat/completions", model, "Sinh văn bản", request_text=request_preview) await filter_or_log(call, request_preview) return await call.run(openai_v1_chat_complete.handle, payload) diff --git a/api/app.py b/api/app.py index 2d10a8e1..9702e8ea 100644 --- a/api/app.py +++ b/api/app.py @@ -1,17 +1,21 @@ from __future__ import annotations +import asyncio from contextlib import asynccontextmanager from threading import Event -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI, Header, HTTPException from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from api import accounts, ai, image_tasks, register, system -from api.support import resolve_web_asset, start_limited_account_watcher +from api.support import resolve_web_asset, start_limited_account_watcher, require_admin +from api.veo_video import handle_video_generation from services.backup_service import backup_service from services.config import config +from services.karpathy_guidelines import refresh_guidelines +from services.quota_watcher import quota_watcher def create_app() -> FastAPI: @@ -23,9 +27,13 @@ async def lifespan(_: FastAPI): thread = start_limited_account_watcher(stop_event) backup_service.start() config.cleanup_old_images() + # Fetch latest Karpathy guidelines + start quota watcher (fire-and-forget) + refresh_guidelines() + watcher_task = asyncio.create_task(quota_watcher.start()) try: yield finally: + await quota_watcher.stop() stop_event.set() thread.join(timeout=1) backup_service.stop() @@ -46,7 +54,12 @@ async def lifespan(_: FastAPI): if config.images_dir.exists(): app.mount("/images", StaticFiles(directory=str(config.images_dir)), name="images") - @app.get("/{full_path:path}", include_in_schema=False) + # Veo video generation + @app.post("/v1/video/generations") + async def create_video(body: dict, authorization: str | None = Header(default=None)): + require_admin(authorization) + return await handle_video_generation(body, authorization) + async def serve_web(full_path: str): asset = resolve_web_asset(full_path) if asset is not None: @@ -58,4 +71,6 @@ async def serve_web(full_path: str): raise HTTPException(status_code=404, detail="Not Found") return FileResponse(fallback) + app.add_api_route("/{full_path:path}", serve_web, methods=["GET", "HEAD"], include_in_schema=False) + return app diff --git a/api/app_lite.py b/api/app_lite.py new file mode 100644 index 00000000..e2c5da2a --- /dev/null +++ b/api/app_lite.py @@ -0,0 +1,41 @@ +from __future__ import annotations + +from contextlib import asynccontextmanager +from threading import Event + +from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware + +from api import accounts, ai, image_tasks, system +from api.support import start_limited_account_watcher +from services.config import config + + +def create_app() -> FastAPI: + app_version = config.app_version + + @asynccontextmanager + async def lifespan(_: FastAPI): + stop_event = Event() + thread = start_limited_account_watcher(stop_event) + config.cleanup_old_images() + try: + yield + finally: + stop_event.set() + thread.join(timeout=1) + + app = FastAPI(title="chatgpt2api-lite", version=app_version, lifespan=lifespan) + 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(system.create_router(app_version)) + + return app diff --git a/api/image_tasks.py b/api/image_tasks.py index 1005241c..c1e01939 100644 --- a/api/image_tasks.py +++ b/api/image_tasks.py @@ -25,7 +25,7 @@ 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)) + call.log("Gọi thất bại", status="failed", error=str(exc.detail)) raise diff --git a/api/system.py b/api/system.py index 431cf696..ded3a56f 100644 --- a/api/system.py +++ b/api/system.py @@ -1,10 +1,11 @@ from __future__ import annotations +import json from urllib.parse import quote from fastapi import APIRouter, Header, HTTPException, Request from fastapi.concurrency import run_in_threadpool -from fastapi.responses import Response, StreamingResponse +from fastapi.responses import HTMLResponse, Response, StreamingResponse from pydantic import BaseModel, ConfigDict from api.support import require_admin, require_identity, resolve_image_base_url @@ -14,6 +15,128 @@ 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 +from services.state_backup import state_backup +from services.backend_router import backend_router +from services.model_cooldown import model_cooldown +from services.providers.opencode import opencode_provider +from services.rate_limit_backoff import rate_limit_backoff +from services.quota_watcher import quota_watcher +from services.ninerouter_backup_import import import_9router_backup_from_api +from services.oauth_service import get_codex_auth_url, exchange_codex_code, get_chatgpt_session_url, detect_token_type + + +def _check_gemini_status() -> dict: + """Check Gemini API and ALL custom provider instances (all ports).""" + import requests as req + + result = { + "gemini_api": "unknown", + "models_count": 0, + "instances": [], # list of all custom provider instances with status + } + + # Check Gemini API directly + try: + provider_config = (config.data.get("providers") or {}).get("gemini_free") or {} + api_key = provider_config.get("api_key") or "" + if api_key: + resp = req.get( + f"https://generativelanguage.googleapis.com/v1beta/models?key={api_key}", + timeout=10 + ) + if resp.status_code == 200: + models = resp.json().get("models", []) + result["gemini_api"] = "available" + result["models_count"] = len(models) + else: + result["gemini_api"] = f"error_{resp.status_code}" + else: + result["gemini_api"] = "no_key" + except Exception as e: + result["gemini_api"] = f"error: {str(e)[:50]}" + + # Check ALL custom providers (all ports/instances) + custom_providers = config.data.get("custom_providers") or {} + for cp_id, cp_cfg in custom_providers.items(): + if not isinstance(cp_cfg, dict) or not cp_cfg.get("enabled", True): + continue + base_url = cp_cfg.get("base_url") or "" + name = cp_cfg.get("name") or cp_id + prefix = cp_cfg.get("prefix") or cp_id + + instance = { + "id": cp_id, + "name": name, + "prefix": prefix, + "base_url": base_url, + "status": "unknown", + "port": base_url.split(":")[-1].rstrip("/") if ":" in base_url else "—", + "models": 0, + "clients": 0, + "entries": 0, + "error": None, + } + + if not base_url: + instance["status"] = "no_url" + else: + try: + resp = req.get(f"{base_url}/health", timeout=5) + if resp.status_code == 200: + data = resp.json() + if data.get("ok"): + instance["status"] = "available" + instance["clients"] = len(data.get("clients") or {}) + instance["entries"] = data.get("storage", {}).get("entries", 0) + else: + instance["status"] = "error" + instance["error"] = data.get("error", "unknown") + else: + instance["status"] = f"http_{resp.status_code}" + # Try /v1/models as fallback for standard OpenAI-compatible APIs + try: + resp2 = req.get(f"{base_url}/v1/models", timeout=5) + if resp2.status_code == 200: + data2 = resp2.json() + models_list = data2.get("data") or data2.get("models") or [] + instance["status"] = "available" + instance["models"] = len(models_list) + else: + instance["error"] = f"/v1/models returned {resp2.status_code}" + except Exception: + instance["error"] = f"health={resp.status_code}, /v1/models unreachable" + except Exception as e: + instance["status"] = "offline" + instance["error"] = str(e)[:80] + + result["instances"].append(instance) + + return result + + +def _is_model_enabled(model_id: str, enabled_by_provider: dict) -> bool: + """Check if a model is in the enabled list. If no providers configured, all models are enabled.""" + if not enabled_by_provider: + return True + # Core models + image models always enabled + if model_id in {"cx/auto", "oc/auto", "chatgpt/auto", "gemini_free/auto", "gpt-image-2", "codex-gpt-image-2"}: + return True + for provider_models in enabled_by_provider.values(): + if isinstance(provider_models, list) and model_id in provider_models: + return True + return False + + +def _create_backup() -> dict: + """Create local full-state backup.""" + payload = state_backup.export_all() + filepath = state_backup.save_to_file(payload) + return { + "status": "ok", + "path": str(filepath), + "size_bytes": filepath.stat().st_size, + "created_at": payload["created_at"], + } class SettingsUpdateRequest(BaseModel): @@ -42,6 +165,12 @@ class LogDeleteRequest(BaseModel): class BackupDeleteRequest(BaseModel): key: str = "" +class Import9RouterRequest(BaseModel): + path: str = "" + +class RestoreRequest(BaseModel): + path: str = "" + def create_router(app_version: str) -> APIRouter: router = APIRouter() @@ -212,4 +341,462 @@ async def delete_image_tag(tag: str, authorization: str | None = Header(default= count = delete_tag(tag) return {"ok": True, "removed_from": count} + # ── Backup / Restore (local full-state) ── + + @router.post("/api/v1/import-9router") + async def import_9router_backup( + body: Import9RouterRequest, + authorization: str | None = Header(default=None), + ): + """Import token từ file backup 9router vào chatgpt2api (theo đường dẫn file).""" + require_admin(authorization) + path = (body.path or "").strip() + if not path: + raise HTTPException(status_code=400, detail={"error": "path is required"}) + + def _import(): + return import_9router_backup_from_api(path) + + result = await run_in_threadpool(_import) + return result + + @router.post("/api/v1/import-9router-upload") + async def import_9router_backup_upload( + body: dict, + authorization: str | None = Header(default=None), + ): + """Import token từ nội dung file backup 9router (upload trực tiếp).""" + require_admin(authorization) + if not isinstance(body, dict): + raise HTTPException(status_code=400, detail={"error": "Invalid JSON body"}) + + def _import(): + import tempfile + import os + with tempfile.NamedTemporaryFile(mode='w', suffix='.json', delete=False, encoding='utf-8') as f: + json.dump(body, f) + tmp_path = f.name + try: + return import_9router_backup_from_api(tmp_path) + finally: + try: + os.unlink(tmp_path) + except Exception: + pass + + result = await run_in_threadpool(_import) + return result + + @router.post("/api/v1/backup") + async def create_local_backup(authorization: str | None = Header(default=None)): + """Export toàn bộ state ra file JSON và lưu local.""" + require_admin(authorization) + result = await run_in_threadpool(_create_backup) + return result + + @router.get("/api/v1/backups") + async def list_local_backups(authorization: str | None = Header(default=None)): + """Danh sách backup local.""" + require_admin(authorization) + return {"backups": state_backup.list_backups()} + + @router.post("/api/v1/restore") + async def restore_from_backup( + body: RestoreRequest, + authorization: str | None = Header(default=None), + ): + """Phục hồi state từ file backup local.""" + require_admin(authorization) + path = (body.path or "").strip() + if not path: + raise HTTPException(status_code=400, detail={"error": "path is required"}) + + def _restore(): + payload = state_backup.load_from_file(path) + return state_backup.import_all(payload) + + report = await run_in_threadpool(_restore) + return report.to_dict() + + @router.delete("/api/v1/backups/{filename}") + async def delete_local_backup(filename: str, authorization: str | None = Header(default=None)): + """Xóa một file backup local.""" + require_admin(authorization) + ok = state_backup.delete_backup(filename) + return {"ok": ok} + + # ── Health / Providers ── + + @router.get("/api/v1/health") + async def system_health(authorization: str | None = Header(default=None)): + """Trạng thái hệ thống: backends, providers, accounts.""" + require_admin(authorization) + from services.account_service import account_service + accounts = account_service.list_accounts() + backoff_stats = rate_limit_backoff.get_stats() + + return { + "status": "ok", + "version": app_version, + "accounts": { + "total": len(accounts), + "active": sum(1 for a in accounts if a.get("status") == "active"), + "limited": sum(1 for a in accounts if a.get("status") == "limited"), + "error": sum(1 for a in accounts if a.get("status") in ("error", "disabled")), + }, + "backoff": backoff_stats, + "quota_watcher": quota_watcher.get_stats(), + "model_cooldown": model_cooldown.get_stats(), + "opencode": { + "available": opencode_provider.is_available, + }, + "gemini": _check_gemini_status(), + } + + @router.get("/api/v1/providers") + async def list_providers(authorization: str | None = Header(default=None)): + """Danh sách provider đang active.""" + require_admin(authorization) + provider_configs = config.data.get("providers") or {} + + providers = [] + for name, cfg in provider_configs.items(): + if isinstance(cfg, dict): + providers.append({ + "name": name, + "enabled": cfg.get("enabled", False), + "noAuth": cfg.get("noAuth", False), + "has_api_key": bool(cfg.get("api_key")), + "has_base_url": bool(cfg.get("base_url")), + }) + + return {"providers": providers} + + @router.get("/api/v1/providers/{provider_id}/models") + async def provider_models(provider_id: str, authorization: str | None = Header(default=None)): + """Lấy danh sách model của một provider.""" + require_admin(authorization) + if provider_id == "opencode": + models = opencode_provider.list_models() + return {"models": models} + return {"models": []} + + @router.post("/api/v1/providers/{provider_id}/test") + async def test_provider(provider_id: str, authorization: str | None = Header(default=None)): + """Test kết nối đến một provider.""" + require_admin(authorization) + if provider_id == "opencode": + available = opencode_provider.is_available + return {"provider": provider_id, "available": available} + if provider_id == "nvidia_nim": + from services.providers.nvidia_nim import nvidia_nim_provider + available = nvidia_nim_provider.is_available + return {"provider": provider_id, "available": available} + raise HTTPException(status_code=404, detail={"error": f"unknown provider: {provider_id}"}) + + # ── OAuth Login ── + + @router.get("/api/oauth/codex/start") + async def start_codex_oauth(request: Request, authorization: str | None = Header(default=None)): + """Generate Codex OAuth URL for user to login.""" + require_admin(authorization) + host = request.headers.get("host", "localhost:1455") + scheme = "https" if request.url.scheme == "https" else "http" + base = config.base_url or f"{scheme}://{host}" + result = get_codex_auth_url(base) + result["help"] = ( + "1. Mở URL trên trong browser. " + "2. Đăng nhập và authorize. " + "3. Sau khi redirect, copy TOÀN Bộ URL trên thanh địa chỉ. " + "4. Dán URL đó vào POST /api/oauth/codex/exchange với body {\"redirect_url\": \"URL_DA_COPY\"}" + ) + return result + + @router.get("/auth/callback") + async def codex_auth_callback(code: str = "", state: str = ""): + """Handle Codex OAuth callback (matches 9router path).""" + if not code or not state: + raise HTTPException(status_code=400, detail={"error": "Missing code or state"}) + try: + result = exchange_codex_code(code, state) + return HTMLResponse(content=f""" + +

{result['message']}

+

Bạn có thể đóng tab này.

+ + + """) + except Exception as exc: + return HTMLResponse(content=f""" + +

Lỗi: {str(exc)}

+

Copy URL này và dùng API exchange thủ công.

+ + """, status_code=400) + + @router.get("/api/oauth/codex/callback") + async def codex_oauth_callback(code: str = "", state: str = ""): + """Handle Codex OAuth callback — exchange code for token.""" + if not code or not state: + raise HTTPException(status_code=400, detail={"error": "Missing code or state"}) + try: + result = exchange_codex_code(code, state) + return HTMLResponse(content=f""" + +

{result['message']}

+

Bạn có thể đóng tab này.

+ + + """) + except Exception as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) + + class CodexExchangeRequest(BaseModel): + redirect_url: str = "" + + @router.post("/api/oauth/codex/exchange") + async def codex_oauth_exchange(body: CodexExchangeRequest, authorization: str | None = Header(default=None)): + """Exchange Codex OAuth code manually — user pastes redirect URL.""" + require_admin(authorization) + from urllib.parse import urlparse, parse_qs + url = (body.redirect_url or "").strip() + if not url: + raise HTTPException(status_code=400, detail={"error": "redirect_url is required"}) + # Replace localhost with actual host if needed + parsed = urlparse(url) + params = parse_qs(parsed.query) + code = (params.get("code") or [""])[0] + state = (params.get("state") or [""])[0] + if not code or not state: + raise HTTPException(status_code=400, detail={"error": "URL không chứa code và state. Copy TOÀN Bộ URL sau khi redirect."}) + try: + result = exchange_codex_code(code, state) + return result + except Exception as exc: + raise HTTPException(status_code=400, detail={"error": str(exc)}) + + @router.get("/api/oauth/session-url") + async def get_session_url(authorization: str | None = Header(default=None)): + """Return chatgpt.com session URL for getting image token.""" + require_admin(authorization) + return {"url": get_chatgpt_session_url()} + + @router.post("/api/oauth/detect-token") + async def detect_token(body: dict, authorization: str | None = Header(default=None)): + """Detect token type (codex vs google).""" + require_admin(authorization) + token = str(body.get("token") or "").strip() + if not token: + raise HTTPException(status_code=400, detail={"error": "token is required"}) + return {"type": detect_token_type(token)} + + # ── Custom Providers ── + + @router.get("/api/v1/custom-providers") + async def list_custom_providers(authorization: str | None = Header(default=None)): + """Lấy danh sách custom providers.""" + require_admin(authorization) + from services.providers.custom_openai import get_custom_providers + return {"custom_providers": get_custom_providers()} + + @router.post("/api/v1/custom-providers") + async def save_custom_provider(body: dict, authorization: str | None = Header(default=None)): + """Thêm hoặc cập nhật một custom provider.""" + require_admin(authorization) + provider = body.get("provider") or {} + if not isinstance(provider, dict): + raise HTTPException(status_code=400, detail={"error": "provider object is required"}) + + provider_id = str(provider.get("prefix") or provider.get("name") or "").strip().lower().replace(" ", "_") + if not provider_id: + raise HTTPException(status_code=400, detail={"error": "provider prefix or name is required"}) + + base_url = str(provider.get("base_url") or "").strip().rstrip("/") + api_key = str(provider.get("api_key") or "").strip() + api_keys = provider.get("api_keys") or [] + if not isinstance(api_keys, list): + api_keys = [] + api_keys = [k.strip() for k in api_keys if k.strip()] + # Support api_key + api_keys combination + if api_key and api_key not in api_keys: + api_keys.insert(0, api_key) + name = str(provider.get("name") or provider_id).strip() + enabled = provider.get("enabled", True) + prefix = str(provider.get("prefix") or provider_id).strip().lower().replace(" ", "_") + + # Validate: test connection with first key + test_key = api_keys[0] if api_keys else api_key + if not test_key: + raise HTTPException(status_code=400, detail={"error": "At least one API key is required"}) + try: + from curl_cffi import requests as cffi_req + resp = cffi_req.get( + f"{base_url}/v1/models", + headers={"Authorization": f"Bearer {test_key}"}, + timeout=10, + ) + if resp.status_code >= 400: + raise HTTPException( + status_code=400, + detail={"error": f"Cannot connect to {base_url}: HTTP {resp.status_code}"}, + ) + except Exception as exc: + if not isinstance(exc, HTTPException): + raise HTTPException( + status_code=400, + detail={"error": f"Cannot connect to {base_url}: {exc}"}, + ) + raise + + # Save to config + custom_providers = dict(config.data.get("custom_providers") or {}) + if not isinstance(custom_providers, dict): + custom_providers = {} + custom_providers[provider_id] = { + "name": name, + "base_url": base_url, + "api_key": api_keys[0] if api_keys else "", + "api_keys": api_keys, + "prefix": prefix, + "enabled": enabled, + } + config.data["custom_providers"] = custom_providers + config._save() + from services.protocol.openai_v1_models import invalidate_models_cache + invalidate_models_cache() + + return { + "custom_providers": custom_providers, + "saved": True, + "provider_id": provider_id, + } + + @router.delete("/api/v1/custom-providers/{provider_id}") + async def delete_custom_provider(provider_id: str, authorization: str | None = Header(default=None)): + """Xóa một custom provider.""" + require_admin(authorization) + custom_providers = dict(config.data.get("custom_providers") or {}) + if not isinstance(custom_providers, dict): + custom_providers = {} + if provider_id in custom_providers: + del custom_providers[provider_id] + config.data["custom_providers"] = custom_providers + config._save() + from services.protocol.openai_v1_models import invalidate_models_cache + invalidate_models_cache() + return {"deleted": True, "provider_id": provider_id} + raise HTTPException(status_code=404, detail={"error": f"provider '{provider_id}' not found"}) + + # ── Model Settings ── + + @router.get("/api/v1/model-settings") + async def get_model_settings(authorization: str | None = Header(default=None)): + """Lấy cấu hình model (enabled models + defaults per provider).""" + require_admin(authorization) + ms = config.data.get("model_settings") or {} + if not isinstance(ms, dict): + ms = {} + return { + "model_settings": { + "enabled_models": ms.get("enabled_models") or {}, + "default_models": ms.get("default_models") or {}, + } + } + + @router.get("/api/v1/available-models") + async def get_available_models(authorization: str | None = Header(default=None), refresh: str = ""): + """Lấy toàn bộ model có sẵn từ cache (nhanh). Thêm ?refresh=true để tải lại.""" + require_admin(authorization) + from services.protocol.openai_v1_models import list_models + force = refresh.lower() == "true" + result = list_models(force_refresh=force) + # Group models by owned_by + grouped: dict[str, list[str]] = {} + for item in result.get("data", []): + owner = str(item.get("owned_by") or "chatgpt") + mid = str(item.get("id") or "").strip() + if mid: + if owner not in grouped: + grouped[owner] = [] + grouped[owner].append(mid) + # Sort each group + for owner in grouped: + grouped[owner].sort() + return {"providers": grouped} + + @router.get("/api/v1/models-with-capabilities") + async def get_models_with_capabilities(authorization: str | None = Header(default=None)): + """Lấy danh sách model kèm phân loại capability (chat/vision/image).""" + require_admin(authorization) + from utils.helper import classify_model_capability, get_model_capability_label + + # Get enabled models from model_settings + ms = config.data.get("model_settings") or {} + if not isinstance(ms, dict): + ms = {} + enabled_by_provider = ms.get("enabled_models") or {} + if not isinstance(enabled_by_provider, dict): + enabled_by_provider = {} + + # Fetch all available models + from services.protocol.openai_v1_models import list_models + result = list_models() + all_models = result.get("data", []) + + enriched: list[dict] = [] + for model in all_models: + mid = str(model.get("id") or "").strip() + if not mid: + continue + caps = classify_model_capability(mid) + enriched.append({ + "id": mid, + "owned_by": str(model.get("owned_by") or ""), + "capability": caps[0] if caps else "chat", # Primary capability + "capabilities": caps, # All capabilities + "capability_labels": [get_model_capability_label(c) for c in caps], + "enabled": _is_model_enabled(mid, enabled_by_provider), + }) + + # Sort: chat first, then vision, then image, then video + def _sort_key(m): + caps = m.get("capabilities", ["chat"]) + if "video" in caps: return 3 + if "image" in caps: return 2 + if "vision" in caps: return 1 + return 0 + enriched.sort(key=lambda m: (_sort_key(m), m["id"])) + + return { + "models": enriched, + "counts": { + "chat": sum(1 for m in enriched if "chat" in (m.get("capabilities") or ["chat"])), + "vision": sum(1 for m in enriched if "vision" in (m.get("capabilities") or [])), + "image": sum(1 for m in enriched if "image" in (m.get("capabilities") or [])), + "video": sum(1 for m in enriched if "video" in (m.get("capabilities") or [])), + }, + } + + @router.post("/api/v1/model-settings") + async def save_model_settings(body: dict, authorization: str | None = Header(default=None)): + """Lưu cấu hình model.""" + require_admin(authorization) + model_settings = body.get("model_settings") + if not isinstance(model_settings, dict): + raise HTTPException(status_code=400, detail={"error": "model_settings is required"}) + enabled = model_settings.get("enabled_models") + defaults = model_settings.get("default_models") + if not isinstance(enabled, dict): + enabled = {} + if not isinstance(defaults, dict): + defaults = {} + config.data["model_settings"] = { + "enabled_models": enabled, + "default_models": defaults, + } + config._save() + from services.protocol.openai_v1_models import invalidate_models_cache + invalidate_models_cache() + return {"model_settings": config.data["model_settings"], "saved": True} + return router diff --git a/api/veo_video.py b/api/veo_video.py new file mode 100644 index 00000000..b8af8333 --- /dev/null +++ b/api/veo_video.py @@ -0,0 +1,80 @@ +""" +Veo Video Generation endpoint — OpenAI-compatible /v1/video/generations. +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +from fastapi import Header, HTTPException +from pydantic import BaseModel, ConfigDict + +from services.config import config +from services.image_providers.veo_video import veo_adapter +from utils.log import logger + + +class VideoGenerationRequest(BaseModel): + model_config = ConfigDict(extra="allow") + model: str = "veo/veo-3.1-generate-preview" + prompt: str + n: int = 1 + aspect_ratio: str = "16:9" + duration: str | None = None + resolution: str | None = None + image: str | None = None # base64 image for image→video + last_frame: str | None = None + + +async def handle_video_generation( + body: dict[str, Any], + authorization: str | None = None, +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Handle POST /v1/video/generations.""" + prompt = str(body.get("prompt") or "") + if not prompt: + raise HTTPException(status_code=400, detail={"error": "prompt is required"}) + + n = max(1, min(1, int(body.get("n") or 1))) # Veo only supports 1 per request + aspect_ratio = str(body.get("aspect_ratio") or "16:9") + duration = body.get("duration") + resolution = body.get("resolution") + image = body.get("image") + last_frame = body.get("last_frame") + + # Get credentials from gemini_free config + providers_cfg = config.data.get("providers") or {} + provider_config = providers_cfg.get("gemini_free") or {} + + credentials = { + "apiKey": str(provider_config.get("api_key") or ""), + "apiKeys": provider_config.get("api_keys") or [], + } + + all_data = [] + for idx in range(n): + try: + result = veo_adapter.generate( + body={ + "prompt": prompt, + "aspect_ratio": aspect_ratio, + "duration": duration, + "resolution": resolution, + "image": image, + "last_frame": last_frame, + }, + credentials=credentials, + ) + all_data.extend(result.get("data") or []) + except Exception as exc: + logger.error({"event": "veo_generation_error", "error": str(exc)}) + raise HTTPException( + status_code=500, + detail={"error": f"Video generation failed: {exc}"}, + ) from exc + + return { + "created": result.get("created", 0) if all_data else 0, + "data": all_data, + } diff --git a/config.json b/config.json index 60536b0f..ee77320d 100644 --- a/config.json +++ b/config.json @@ -46,5 +46,42 @@ "images": false } }, - "image_account_concurrency": 3 + "image_account_concurrency": 3, + "backends": { + "chat": ["chatgpt", "opencode", "gemini_free", "openrouter"], + "image": ["chatgpt", "sdwebui", "huggingface", "cloudflare"], + "default_chat": "auto", + "default_image": "1792x1024" + }, + "providers": { + "opencode": {"enabled": true, "noAuth": true}, + "gemini_free": {"enabled": false, "api_key": ""}, + "openrouter": {"enabled": false, "api_key": ""}, + "sdwebui": {"enabled": false, "base_url": "http://localhost:7860"}, + "huggingface": {"enabled": false, "api_key": ""}, + "cloudflare_ai": {"enabled": false, "account_id": "", "api_token": ""}, + "serper": {"enabled": false, "api_key": ""}, + "searxng": {"enabled": false, "base_url": "http://localhost:8080"}, + "brave": {"enabled": false, "api_key": ""} + }, + "rate_limit": { + "backoff_base_ms": 2000, + "backoff_max_ms": 300000, + "max_levels": 15 + }, + "combo_models": { + "ha-agent": ["oc/auto", "cx/auto", "chatgpt/auto"], + "ha-agent-image": ["sdwebui/stable-diffusion", "chatgpt/gpt-image-2"] + }, + "search": { + "enabled": true, + "backend": "chatgpt", + "auto_detect": true, + "max_results": 3, + "inject_as": "user_message" + }, + "ninerouter": { + "base_url": "http://localhost:20128", + "api_key": "" + } } diff --git a/docker-compose.lite.yml b/docker-compose.lite.yml new file mode 100644 index 00000000..2816fc8d --- /dev/null +++ b/docker-compose.lite.yml @@ -0,0 +1,32 @@ +services: + app: + build: + context: . + dockerfile: Dockerfile.lite + container_name: chatgpt2api-lite + restart: unless-stopped + ports: + - "3030:80" + volumes: + - ./data:/app/data + environment: + # === REQUIRED: Authentication key === + # Used by hass_local_openai_llm to authenticate API calls + - CHATGPT2API_AUTH_KEY=sk-your-secret-key + + # === REQUIRED: ChatGPT tokens (at least one) === + # Multiple accounts supported via numbered env vars + - CHATGPT_TOKEN_1=your_chatgpt_token_here + # - CHATGPT_TOKEN_2=another_token + # - CHATGPT_TOKEN_3=third_token + + # === OPTIONAL: Storage backend === + # Supported: json (default), sqlite, postgres, git + - STORAGE_BACKEND=json + + # === OPTIONAL: Database URL (for sqlite/postgres) === + # - DATABASE_URL=sqlite:///app/data/accounts.db + # - DATABASE_URL=postgresql://user:password@host:5432/dbname + + # === OPTIONAL: Global system prompt === + # - GLOBAL_SYSTEM_PROMPT=You are a helpful assistant. diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 28890190..dbe11e4a 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -9,7 +9,6 @@ services: - "8000:80" volumes: - ./data:/app/data - - ./config.json:/app/config.json environment: STORAGE_BACKEND: sqlite DATABASE_URL: sqlite:////app/data/accounts.db diff --git a/docker-compose.yml b/docker-compose.yml index a80dc643..6adf0425 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,29 +1,57 @@ +# ============================================================ +# chatgpt2api — Docker Compose cho Portainer +# Tích hợp 9router-chat: OpenCode free, image adapters, search, backup +# ============================================================ +# +# Cách dùng trong Portainer: +# 1. Tạo Stack mới → Paste nội dung file này +# 2. Sửa CHATGPT2API_AUTH_KEY thành key của bạn +# 3. Deploy +# +# Hoặc dùng CLI: +# docker compose up -d +# ============================================================ + services: - app: - image: ghcr.io/basketikun/chatgpt2api:latest + chatgpt2api: + # ── Image từ GitHub Container Registry (khuyên dùng) ── + image: ghcr.io/tritue2011/chatgpt2api:latest + + # ── Hoặc build từ source ── + # build: + # context: . + # dockerfile: Dockerfile + container_name: chatgpt2api restart: unless-stopped + ports: - "3000:80" + volumes: - - ./data:/app/data - - ./config.json:/app/config.json + # Dữ liệu persistent: accounts, config, models cache, ảnh, backup, logs + # Dùng bind mount đến thư mục thật trên host → không bao giờ mất khi build lại + - ./chatgpt2api-data:/app/data + 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 + # [BẮT BUỘC] Auth key bảo vệ API + CHATGPT2API_AUTH_KEY: your_secret_key_here + + # [Tùy chọn] Public base URL cho link ảnh + # CHATGPT2API_BASE_URL: https://your-domain.com + + # [Tùy chọn] Storage backend: json (mặc định), sqlite, postgres, git + STORAGE_BACKEND: json + + # [Tùy chọn] Database URL nếu dùng sqlite/postgres + # DATABASE_URL: sqlite:///app/data/accounts.db + + # Healthcheck — kiểm tra API có sống không + healthcheck: + test: ["CMD", "python3", "-c", "import urllib.request; urllib.request.urlopen('http://localhost:80/version')"] + interval: 30s + timeout: 10s + retries: 3 + start_period: 15s + +# Không cần khai báo volumes ở cuối nữa — dùng bind mount ./chatgpt2api-data diff --git a/main_lite.py b/main_lite.py new file mode 100644 index 00000000..64b447d0 --- /dev/null +++ b/main_lite.py @@ -0,0 +1,9 @@ +from __future__ import annotations + +import uvicorn +from api.app_lite import create_app + +app = create_app() + +if __name__ == "__main__": + uvicorn.run(app, access_log=False, log_level="info") diff --git a/services/account_service.py b/services/account_service.py index e273bec4..79f64620 100644 --- a/services/account_service.py +++ b/services/account_service.py @@ -10,8 +10,30 @@ LOG_TYPE_ACCOUNT, log_service, ) +from services.rate_limit_backoff import rate_limit_backoff from services.storage.base import StorageBackend from utils.helper import anonymize_token +from utils.log import logger + +# Status migration: Chinese → English (backward compatible) +_STATUS_MIGRATION = { + "正常": "active", + "限流": "limited", + "异常": "error", + "禁用": "disabled", +} +_STATUS_REVERSE = {v: k for k, v in _STATUS_MIGRATION.items()} + +DISPLAY_STATUS = { + "active": "Hoạt động", + "limited": "Giới hạn", + "error": "Lỗi", + "disabled": "Vô hiệu", +} + + +# NoAuth providers — virtual connections (port from 9router FREE_PROVIDERS) +NO_AUTH_PROVIDERS = {"opencode"} class AccountService: @@ -40,7 +62,7 @@ def _save_accounts(self) -> None: def _is_image_account_available(account: dict) -> bool: if not isinstance(account, dict): return False - if account.get("status") in {"禁用", "限流", "异常"}: + if account.get("status") in {"disabled", "limited", "error"}: return False if bool(account.get("image_quota_unknown")): return True @@ -55,7 +77,9 @@ def _normalize_account(self, item: dict) -> dict | None: normalized = dict(item) normalized["access_token"] = access_token normalized["type"] = normalized.get("type") or "free" - normalized["status"] = normalized.get("status") or "正常" + # Auto-migrate Chinese status to English + raw_status = normalized.get("status") or "active" + normalized["status"] = _STATUS_MIGRATION.get(raw_status, raw_status) 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 @@ -135,7 +159,7 @@ def get_text_access_token(self, excluded_tokens: set[str] | None = None) -> str: candidates = [ token for account in self._accounts.values() - if account.get("status") not in {"禁用", "异常"} + if account.get("status") not in {"disabled", "error"} and (token := account.get("access_token") or "") and token not in excluded ] @@ -162,14 +186,14 @@ def mark_text_used(self, access_token: str) -> None: 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}) + self.update_account(access_token, {"status": "error", "quota": 0}) return False removed = bool(self.delete_accounts([access_token])["removed"]) if removed: - log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", + log_service.add(LOG_TYPE_ACCOUNT, "Tự động xóa tài khoản lỗi", {"source": event, "token": anonymize_token(access_token)}) elif access_token: - self.update_account(access_token, {"status": "异常", "quota": 0}) + self.update_account(access_token, {"status": "error", "quota": 0}) return removed def get_account(self, access_token: str) -> dict | None: @@ -188,7 +212,7 @@ def list_limited_tokens(self) -> list[str]: return [ token for item in self._accounts.values() - if item.get("status") == "限流" + if item.get("status") == "limited" and (token := item.get("access_token") or "") ] @@ -218,10 +242,48 @@ def add_accounts(self, tokens: list[str]) -> dict: 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} 个", + log_service.add(LOG_TYPE_ACCOUNT, f"新增 {added} tài khoản,跳过 {skipped} 个", {"added": added, "skipped": skipped}) return {"added": added, "skipped": skipped, "items": items} + def add_accounts_with_type(self, tokens: list[str], account_type: str = "codex") -> dict: + """Add accounts with a specific type (e.g. 'codex' for 9router OAuth tokens).""" + tokens = list(dict.fromkeys(token for token in tokens if token)) + if not tokens: + return {"added": 0, "skipped": 0, "items": self.list_accounts()} + + with self._lock: + added = 0 + skipped = 0 + updated = 0 + for access_token in tokens: + current = self._accounts.get(access_token) + if current is not None: + # Merge type: add new type to existing (e.g. existing "free" + new "codex" → "free,codex") + existing_types = set(str(current.get("type") or "").split(",")) + new_types = set(str(account_type).split(",")) + merged = ",".join(sorted(existing_types | new_types)) + if merged != str(current.get("type") or ""): + current["type"] = merged + updated += 1 + logger.info({"event": "account_type_merged", "token": anonymize_token(access_token), "new_type": merged}) + else: + skipped += 1 + continue + added += 1 + account = self._normalize_account({ + "access_token": access_token, + "type": account_type, + "status": "active", + }) + 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"Thêm {added} tài khoản {account_type}, cập nhật {updated}, bỏ qua {skipped}", + {"added": added, "skipped": skipped, "updated": updated, "type": account_type}) + return {"added": added, "skipped": skipped, "updated": updated, "items": items} + def delete_accounts(self, tokens: list[str]) -> dict: target_set = set(token for token in tokens if token) if not target_set: @@ -236,7 +298,7 @@ def delete_accounts(self, tokens: list[str]) -> dict: else: self._index = 0 self._save_accounts() - log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} 个账号", {"removed": removed}) + log_service.add(LOG_TYPE_ACCOUNT, f"删除 {removed} tài khoản", {"removed": removed}) items = [dict(item) for item in self._accounts.values()] return {"removed": removed, "items": items} @@ -250,14 +312,14 @@ def update_account(self, access_token: str, updates: dict) -> dict | 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: + if account.get("status") == "limited" 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)}) + log_service.add(LOG_TYPE_ACCOUNT, "Tự động xóa tài khoản giới hạn", {"token": anonymize_token(access_token)}) return None self._accounts[access_token] = account self._save_accounts() - log_service.add(LOG_TYPE_ACCOUNT, "更新账号", + log_service.add(LOG_TYPE_ACCOUNT, "Cập nhật tài khoản", {"token": anonymize_token(access_token), "status": account.get("status")}) return dict(account) return None @@ -278,19 +340,19 @@ def mark_image_result(self, access_token: str, success: bool) -> dict | None: 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["status"] = "limited" next_item["restore_at"] = next_item.get("restore_at") or None - elif next_item.get("status") == "限流": - next_item["status"] = "正常" + elif next_item.get("status") == "limited": + next_item["status"] = "active" 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: + if account.get("status") == "limited" 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)}) + log_service.add(LOG_TYPE_ACCOUNT, "Tự động xóa tài khoản giới hạn", {"token": anonymize_token(access_token)}) return None self._accounts[access_token] = account self._save_accounts() @@ -339,4 +401,111 @@ def refresh_accounts(self, access_tokens: list[str]) -> dict[str, Any]: } + def get_health_score(self, access_token: str) -> float: + """Calculate health score for an account (0.0-1.0). + + Ported from 9router health scoring pattern: + - 0.35: rate-limit status + - 0.20: response latency (placeholder) + - 0.20: concurrency saturation + - 0.15: token last used recency + - 0.10: success/fail ratio + """ + account = self.get_account(access_token) + if not account: + return 0.0 + + score = 0.0 + + # Rate-limit status (0.35) + status = str(account.get("status") or "active") + if status == "active": + score += 0.35 + elif status == "limited": + score += 0.0 + else: + score += 0.1 + + # Concurrency saturation (0.20) + max_conc = max(1, int(config.image_account_concurrency or 1)) + inflight = int(self._image_inflight.get(access_token, 0)) + saturation = inflight / max_conc + score += (1 - saturation) * 0.20 + + # Token recency (0.15) + last_used = account.get("last_used_at") + if last_used: + try: + from datetime import datetime + last_dt = datetime.strptime(str(last_used), "%Y-%m-%d %H:%M:%S") + age_minutes = (datetime.now() - last_dt).total_seconds() / 60 + if age_minutes < 5: + score += 0.15 + elif age_minutes < 30: + score += 0.10 + else: + score += 0.03 + except (ValueError, TypeError): + score += 0.05 + else: + score += 0.05 + + # Success/fail ratio (0.10) + success = int(account.get("success") or 0) + fail = int(account.get("fail") or 0) + total = success + fail + if total > 0: + score += (success / total) * 0.10 + else: + score += 0.05 + + # Latency placeholder (0.20) — default to mid-range + score += 0.10 + + return max(0.0, min(1.0, score)) + + def get_provider_credentials( + self, + provider_id: str, + exclude_connection_ids: set[str] | None = None, + model: str = "", + ) -> dict[str, Any] | None: + """Get credentials for a provider, supporting noAuth virtual connections. + + Ported from 9router src/sse/services/auth.js getProviderCredentials(). + Returns None if no credentials available. + + For noAuth providers (opencode): returns a virtual connection with + id="noauth" and accessToken="public". + """ + # Check for noAuth provider first (port from 9router FREE_PROVIDERS check) + if provider_id in NO_AUTH_PROVIDERS: + return { + "id": "noauth", + "connectionName": "Public", + "isActive": True, + "accessToken": "public", + "noAuth": True, + } + + # For chatgpt provider, use existing token pool + if provider_id == "chatgpt": + token = self.get_text_access_token(exclude_connection_ids) + if not token: + return None + return { + "id": anonymize_token(token), + "connectionName": "ChatGPT", + "isActive": True, + "accessToken": token, + "noAuth": False, + } + + return None + + def is_noauth_provider(self, provider_id: str) -> bool: + """Check if a provider uses noAuth virtual connections.""" + return provider_id in NO_AUTH_PROVIDERS + + account_service = AccountService(config.get_storage_backend()) diff --git a/services/auth_service.py b/services/auth_service.py index ef9a29bf..21b6ac1a 100644 --- a/services/auth_service.py +++ b/services/auth_service.py @@ -35,7 +35,7 @@ def _clean(value: object) -> str: @staticmethod def _default_name(role: object) -> str: - return "管理员密钥" if str(role or "").strip().lower() == "admin" else "普通用户" + return "Khóa quản trị" if str(role or "").strip().lower() == "admin" else "Người dùng" def _normalize_item(self, raw: object) -> dict[str, object] | None: if not isinstance(raw, dict): @@ -105,13 +105,13 @@ def _has_key_hash_locked(self, key_hash: str, *, exclude_id: str = "") -> bool: def _build_key_hash_locked(self, raw_key: str, *, exclude_id: str = "") -> str: candidate = self._clean(raw_key) if not candidate: - raise ValueError("请输入新的专用密钥") + raise ValueError("Vui lòng nhập khóa mới") admin_key = self._clean(config.auth_key) if admin_key and hmac.compare_digest(candidate, admin_key): - raise ValueError("这个密钥和管理员密钥冲突了,请换一个新的密钥") + raise ValueError("这个密钥和Khóa quản trị冲突了,请换一个新的密钥") key_hash = _hash_key(candidate) if self._has_key_hash_locked(key_hash, exclude_id=exclude_id): - raise ValueError("这个专用密钥已经存在,请换一个新的密钥") + raise ValueError("Khóa này đã tồn tại, vui lòng dùng khóa khác") return key_hash def _has_name_locked(self, name: str, *, role: AuthRole | None = None, exclude_id: str = "") -> bool: @@ -144,7 +144,7 @@ def _build_name_locked(self, name: str, *, role: AuthRole, exclude_id: str = "") 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("这个名称已经在使用中了,换一个更容易区分的名称吧") + raise ValueError("Tên này đã được dùng, hãy chọn tên khác") return candidate def create_key(self, *, role: AuthRole, name: str = "") -> tuple[dict[str, object], str]: diff --git a/services/backend_router.py b/services/backend_router.py new file mode 100644 index 00000000..15c05894 --- /dev/null +++ b/services/backend_router.py @@ -0,0 +1,282 @@ +""" +BackendRouter — route requests to the appropriate AI backend. + +Port pattern from 9router getProviderCredentials() + combo model routing: +- Model prefix determines provider: oc/ → OpenCode, gw/ → Grok Web, etc. +- Payload > 24KB → ưu tiên provider không giới hạn (opencode, gemini, openrouter) +- Payload ≤ 24KB → dùng ChatGPT free +- Image models stay on ChatGPT DALL-E path +- Combo models fallback qua nhiều provider +""" + +from __future__ import annotations + +import json +from typing import Any + +from services.config import config +from utils.helper import IMAGE_MODELS + +# Provider prefixes ported from 9router src/shared/constants/providers.js +PROVIDER_PREFIXES: dict[str, str] = { + "9r/": "ninerouter", + "cx/": "openai_oauth", + "codex/": "openai_oauth", + "oc/": "opencode", + "ocg/": "opencode_go", + "gemini_free/": "gemini_free", + "gemini/": "gemini_free", + "gw/": "grok_web", + "pw/": "perplexity_web", + "gc/": "gemini_cli", + "kr/": "kiro", + "qw/": "qwen", + "if/": "iflow", + "gh/": "github", + "cu/": "cursor", + "cc/": "claude", + "cx/": "codex", + "nv/": "nvidia_nim", +} + +# NoAuth providers — no credentials needed (port from 9router FREE_PROVIDERS) +NO_AUTH_PROVIDERS: set[str] = {"opencode"} + +# Providers that accept API key (not OAuth) +API_KEY_PROVIDERS: set[str] = { + "gemini_free", + "openrouter", + "deepseek", + "groq", + "xai", + "mistral", + "perplexity", + "together", + "nvidia_nim", +} + +# Image providers from 9router image adapter system +IMAGE_PROVIDER_PREFIXES: dict[str, str] = { + "sdwebui/": "sdwebui", + "comfyui/": "comfyui", + "huggingface/": "huggingface", + "fal-ai/": "fal_ai", + "stability/": "stability_ai", + "bfl/": "black_forest_labs", + "cloudflare/": "cloudflare_ai", + "recraft/": "recraft", + "runwayml/": "runwayml", + "nv-image/": "nvidia_nim_image", + "gemini-image/": "gemini", +} + + +class BackendRoute: + """Result of routing decision.""" + def __init__( + self, + provider: str, + model: str, + no_auth: bool = False, + api_key: str = "", + base_url: str = "", + is_image: bool = False, + fallback_providers: list[str] | None = None, + ): + self.provider = provider + self.model = model + self.no_auth = no_auth + self.api_key = api_key + self.base_url = base_url + self.is_image = is_image + self.fallback_providers = fallback_providers or [] + + +class BackendRouter: + """ + Route request đến backend phù hợp nhất: + - Payload > 24KB → ưu tiên provider không giới hạn (opencode, gemini, openrouter) + - Payload ≤ 24KB → có thể dùng ChatGPT free + - Model có prefix oc/ → OpenCode, gw/ → Grok Web, v.v. + - Combo model → fallback qua nhiều provider + """ + + # Payload threshold for free ChatGPT accounts (24KB) + FREE_PAYLOAD_LIMIT = 24_000 + + # Default model per provider (for "auto" resolution) + PROVIDER_DEFAULT_MODELS: dict[str, str] = { + "ninerouter": "auto", + "openai_oauth": "gpt-5.3-codex", + "opencode": "nemotron-3-super-free", + "chatgpt": "auto", + "gemini_free": "gemini-3-flash-preview", + "openrouter": "openai/gpt-4o", + "nvidia_nim": "openai/gpt-oss-120b", + } + + @staticmethod + def resolve_model(model_str: str) -> tuple[str, str]: + """Parse model string → (provider, model_name). + + Examples: + "gpt-4" → ("chatgpt", "gpt-4") + "oc/nemotron-free" → ("opencode", "nemotron-free") + "sdwebui/sd-v1.5" → ("sdwebui", "sd-v1.5") + "huggingface/black-forest-labs/FLUX.1-schnell" → ("huggingface", "black-forest-labs/FLUX.1-schnell") + """ + model_str = str(model_str or "").strip() + + # Check image provider prefixes first + for prefix, provider in IMAGE_PROVIDER_PREFIXES.items(): + if model_str.startswith(prefix): + return (provider, model_str[len(prefix):]) + + # Check chat provider prefixes + for prefix, provider in PROVIDER_PREFIXES.items(): + if model_str.startswith(prefix): + return (provider, model_str[len(prefix):]) + + # Check custom providers (dynamic, configured via UI) + from services.providers.custom_openai import resolve_custom_provider + custom_cfg, custom_rest = resolve_custom_provider(model_str) + if custom_cfg is not None: + provider_id = str(custom_cfg.get("prefix") or "") + return (f"custom:{provider_id}", custom_rest) + + # Default: use ChatGPT + return ("chatgpt", model_str) + + @staticmethod + def is_image_model(model_str: str) -> bool: + """Check if model is an image generation model.""" + model_str = str(model_str or "").strip() + if model_str in IMAGE_MODELS: + return True + for prefix in IMAGE_PROVIDER_PREFIXES: + if model_str.startswith(prefix): + return True + return False + + @staticmethod + def get_payload_size(messages: list[dict[str, Any]]) -> int: + """Calculate JSON payload size in bytes.""" + try: + payload = json.dumps(messages, ensure_ascii=False, default=str) + return len(payload.encode("utf-8")) + except Exception: + return 0 + + def route( + self, + model: str, + messages: list[dict[str, Any]] | None = None, + payload_size: int | None = None, + ) -> BackendRoute: + """Determine the best backend for a request. + + Args: + model: Model string from request + messages: Normalized messages (for payload size calculation) + payload_size: Pre-calculated payload size in bytes (optional) + + Returns: + BackendRoute with provider, model, auth info + """ + provider, resolved_model = self.resolve_model(model) + is_image = self.is_image_model(model) + + # Resolve "auto" to provider's default model (check user config first) + if resolved_model == "auto" or not resolved_model: + provider_cfg = (config.data.get("providers") or {}).get(provider) or {} + user_model = str(provider_cfg.get("model") or "").strip() + resolved_model = user_model or self.PROVIDER_DEFAULT_MODELS.get(provider, "auto") + + # Calculate payload size if not provided + if payload_size is None and messages: + payload_size = self.get_payload_size(messages) + + # Image models always use their configured provider + if is_image: + if provider == "chatgpt": + # ChatGPT DALL-E — existing path + return BackendRoute( + provider="chatgpt", + model=model, + is_image=True, + ) + else: + # External image provider (sdwebui, huggingface, etc.) + return BackendRoute( + provider=provider, + model=resolved_model, + no_auth=provider in NO_AUTH_PROVIDERS, + is_image=True, + ) + + # Text chat routing + if provider == "chatgpt": + # If payload is large and we have free providers, suggest fallback + if payload_size and payload_size > self.FREE_PAYLOAD_LIMIT: + # Check if OpenCode is enabled in config + opencode_config = (config.data.get("providers") or {}).get("opencode") or {} + if opencode_config.get("enabled", True): + return BackendRoute( + provider="opencode", + model=model if model != "auto" else "auto", + no_auth=True, + fallback_providers=["chatgpt"], + ) + + # Use ChatGPT as normal + return BackendRoute( + provider="chatgpt", + model=model, + ) + + # Non-ChatGPT provider (opencode, gemini_free, etc.) + provider_config = (config.data.get("providers") or {}).get(provider) or {} + return BackendRoute( + provider=provider, + model=resolved_model or model, + no_auth=provider in NO_AUTH_PROVIDERS, + api_key=str(provider_config.get("api_key") or ""), + base_url=str(provider_config.get("base_url") or ""), + fallback_providers=["chatgpt"], + ) + + def route_combo(self, combo_name: str) -> list[BackendRoute]: + """Resolve a combo model into its fallback chain (case-insensitive).""" + models = self._get_combo_models(combo_name) + if not models: + return [] + + routes: list[BackendRoute] = [] + for model_str in models: + route = self.route(str(model_str)) + routes.append(route) + + return routes + + def is_combo(self, model_str: str) -> bool: + """Check if a model string is a combo name (case-insensitive).""" + combos = config.data.get("combo_models") or {} + if not isinstance(combos, dict): + return False + model_lower = model_str.lower().strip() + return any(k.lower().strip() == model_lower for k in combos) + + def _get_combo_models(self, combo_name: str) -> list[str] | None: + """Get combo model list by name (case-insensitive).""" + combos = config.data.get("combo_models") or {} + if not isinstance(combos, dict): + return None + name_lower = combo_name.lower().strip() + for k, v in combos.items(): + if k.lower().strip() == name_lower and isinstance(v, list): + return v + return None + + +# Singleton +backend_router = BackendRouter() diff --git a/services/config.py b/services/config.py index 74b46bb7..30082fcd 100644 --- a/services/config.py +++ b/services/config.py @@ -12,6 +12,7 @@ BASE_DIR = Path(__file__).resolve().parents[1] DATA_DIR = BASE_DIR / "data" CONFIG_FILE = BASE_DIR / "config.json" +CONFIG_DATA_FILE = DATA_DIR / "config.json" VERSION_FILE = BASE_DIR / "VERSION" BACKUP_STATE_FILE = DATA_DIR / "backup_state.json" @@ -119,6 +120,12 @@ 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")) + + # HA addon fallback: read from /data/options.json if auth_key still empty + if _is_invalid_auth_key(auth_key): + addon_options = _read_json_object(Path("/data/options.json"), name="HA addon options") + auth_key = _normalize_auth_key(addon_options.get("auth_key") or "") + if _is_invalid_auth_key(auth_key): raise ValueError( "❌ auth-key 未设置!\n" @@ -153,14 +160,28 @@ def __init__(self, path: Path): ) def _load(self) -> dict[str, object]: + # Load from data dir first (persists across restarts), fallback to root + if CONFIG_DATA_FILE.exists(): + return _read_json_object(CONFIG_DATA_FILE, name="data/config.json") 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") + DATA_DIR.mkdir(parents=True, exist_ok=True) + CONFIG_DATA_FILE.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + # Also sync to root if different (backward compat) + if self.path != CONFIG_DATA_FILE: + 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")) + # Priority: 1) ENV var 2) HA addon config 3) config.json + key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY")) + if _is_invalid_auth_key(key): + addon_options = _read_json_object(Path("/data/options.json"), name="HA addon options") + key = _normalize_auth_key(addon_options.get("auth_key") or "") + if _is_invalid_auth_key(key): + key = _normalize_auth_key(str(self.data.get("auth-key") or "")) + return key @property def accounts_file(self) -> Path: @@ -230,6 +251,22 @@ def ai_review(self) -> dict[str, object]: def global_system_prompt(self) -> str: return str(self.data.get("global_system_prompt") or "").strip() + @property + def karpathy_mode(self) -> bool: + return _normalize_bool(self.data.get("karpathy_mode"), False) + + @property + def auto_refresh_enabled(self) -> bool: + return _normalize_bool(self.data.get("auto_refresh_enabled"), True) + + @property + def default_image_size(self) -> str: + size = str(self.data.get("default_image_size") or "1792x1024").strip() + # Validate: must be WxH format + if "x" in size: + return size + return "1792x1024" + @property def images_dir(self) -> Path: path = DATA_DIR / "images" @@ -258,11 +295,16 @@ def cleanup_old_images(self) -> int: @property def base_url(self) -> str: - return str( + url = str( os.getenv("CHATGPT2API_BASE_URL") or self.data.get("base_url") or "" ).strip().rstrip("/") + # HA addon fallback + if not url: + addon_options = _read_json_object(Path("/data/options.json"), name="HA addon options") + url = str(addon_options.get("base_url") or "").strip().rstrip("/") + return url @property def app_version(self) -> str: @@ -299,6 +341,12 @@ def update(self, data: dict[str, object]) -> dict[str, object]: next_data.pop("backup_state", None) self.data = next_data self._save() + # Invalidate model cache when settings change (combo_models, providers, etc.) + try: + from services.protocol.openai_v1_models import invalidate_models_cache + invalidate_models_cache() + except Exception: + pass return self.get() def get_backup_settings(self) -> dict[str, object]: diff --git a/services/image_providers/__init__.py b/services/image_providers/__init__.py new file mode 100644 index 00000000..95de04a6 --- /dev/null +++ b/services/image_providers/__init__.py @@ -0,0 +1,61 @@ +""" +Image Adapter Registry — port from 9router open-sse/handlers/imageProviders/index.js. + +Maps provider keys to adapter instances. +""" + +from __future__ import annotations + +from typing import Any + +from services.image_providers._base import BaseImageAdapter +from services.image_providers.sdwebui import SDWebUIAdapter +from services.image_providers.huggingface import HuggingFaceAdapter +from services.image_providers.cloudflare import CloudflareAIAdapter +from services.image_providers.fal_ai import FalAIAdapter +from services.image_providers.stability import StabilityAIAdapter +from services.image_providers.bfl import BFLAdapter +from services.image_providers.gemini_image import GeminiImageAdapter +from services.image_providers.nvidia_nim_image import NvidiaNimImageAdapter + + +# Registry — matches 9router ADAPTERS mapping +IMAGE_ADAPTERS: dict[str, BaseImageAdapter] = { + "sdwebui": SDWebUIAdapter(), + "huggingface": HuggingFaceAdapter(), + "cloudflare_ai": CloudflareAIAdapter(), + "fal_ai": FalAIAdapter(), + "stability_ai": StabilityAIAdapter(), + "black_forest_labs": BFLAdapter(), + "gemini": GeminiImageAdapter(), + "nvidia_nim_image": NvidiaNimImageAdapter(), +} + + +def get_image_adapter(provider: str) -> BaseImageAdapter | None: + """Look up an image adapter by provider key. + + For custom providers (custom: prefix), returns a generic adapter + that uses the chat completions endpoint for image generation. + """ + if provider in IMAGE_ADAPTERS: + return IMAGE_ADAPTERS[provider] + if provider.startswith("custom:"): + from services.image_providers.custom_openai_image import CustomOpenAIImageAdapter + cp_id = provider[len("custom:"):] + return CustomOpenAIImageAdapter(cp_id) + return None + + +def is_image_provider(provider: str) -> bool: + """Check if a provider has an image adapter registered.""" + return provider in IMAGE_ADAPTERS + + +# NoAuth image providers (no API key needed) +NO_AUTH_IMAGE_PROVIDERS: set[str] = {"sdwebui"} + + +def is_noauth_image_provider(provider: str) -> bool: + """Check if an image provider requires no authentication.""" + return provider in NO_AUTH_IMAGE_PROVIDERS diff --git a/services/image_providers/_base.py b/services/image_providers/_base.py new file mode 100644 index 00000000..4b3cb64d --- /dev/null +++ b/services/image_providers/_base.py @@ -0,0 +1,122 @@ +""" +Image Provider Adapters — port from 9router open-sse/handlers/imageProviders/. + +Base utilities shared across all image adapters: +- POLL_INTERVAL_MS / POLL_TIMEOUT_MS for async adapters +- size_to_aspect_ratio: convert OpenAI size string to width/height +- url_to_base64: download image URL and convert to base64 +- Size constants and default (16:9) +""" + +from __future__ import annotations + +import base64 +import time +from typing import Any + +from curl_cffi import requests + +# Polling config (port from 9router _base.js) +POLL_INTERVAL_S = 1.5 +POLL_TIMEOUT_S = 120 + +# OpenAI size → width x height (16:9 mặc định) +SIZE_MAP: dict[str, tuple[int, int]] = { + "1024x1024": (1024, 1024), # 1:1 + "1792x1024": (1792, 1024), # 16:9 ← DEFAULT + "1024x1792": (1024, 1792), # 9:16 + "1280x896": (1280, 896), # ~4:3 landscape + "896x1280": (896, 1280), # ~4:3 portrait +} + +DEFAULT_SIZE = "1792x1024" + + +def size_to_width_height(size: str | None) -> tuple[int, int]: + """Convert OpenAI size string → (width, height). Default 16:9.""" + if not size: + return SIZE_MAP[DEFAULT_SIZE] + if size in SIZE_MAP: + return SIZE_MAP[size] + # Try parsing "WxH" format + try: + parts = size.split("x") + if len(parts) == 2: + return (int(parts[0]), int(parts[1])) + except (ValueError, TypeError): + pass + return SIZE_MAP[DEFAULT_SIZE] + + +def size_to_aspect_ratio(size: str | None) -> str: + """Convert OpenAI size → aspect ratio string (e.g. '16:9').""" + w, h = size_to_width_height(size) + if w == h: + return "1:1" + if w > h: + if w / h > 1.6: + return "16:9" + return "4:3" + else: + if h / w > 1.6: + return "9:16" + return "3:4" + + +def url_to_base64(url: str, timeout: int = 30) -> str: + """Download image from URL and return as base64 string.""" + resp = requests.get(url, timeout=timeout) + resp.raise_for_status() + return base64.b64encode(resp.content).decode("ascii") + + +def now_sec() -> int: + """Current time in seconds (Unix timestamp).""" + return int(time.time()) + + +def sleep_s(seconds: float) -> None: + """Sleep for seconds.""" + time.sleep(seconds) + + +class BaseImageAdapter: + """Base class for image generation adapters. + + Ported from 9router imageProviders adapters. + Each adapter implements: + - build_url(model, credentials) -> str + - build_body(model, body) -> dict + - build_headers(credentials, request_body, model, body) -> dict + - normalize(parsed) -> dict (OpenAI-compatible format) + - parse_response(response) -> dict | None (optional, for async/polling) + """ + + no_auth: bool = False + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + raise NotImplementedError + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + raise NotImplementedError + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + raise NotImplementedError + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Optional: custom response parsing (async polling, SSE, etc.).""" + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """Convert provider response to OpenAI format: {created, data: [{b64_json}]}.""" + raise NotImplementedError + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + """Quick connection test.""" + return True diff --git a/services/image_providers/bfl.py b/services/image_providers/bfl.py new file mode 100644 index 00000000..6545a9b6 --- /dev/null +++ b/services/image_providers/bfl.py @@ -0,0 +1,125 @@ +""" +Black Forest Labs Adapter — port from 9router blackForestLabs.js. + +BFL/FLUX API: https://api.bfl.ai/v1 +Async polling-based adapter. +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import ( + BaseImageAdapter, + POLL_INTERVAL_S, + POLL_TIMEOUT_S, + now_sec, + sleep_s, +) +from utils.log import logger + + +class BFLAdapter(BaseImageAdapter): + """Black Forest Labs (FLUX) adapter — async polling. + + Model format: bfl/flux-pro-1.1, bfl/flux-dev, bfl/flux-schnell + """ + + BASE_URL = "https://api.bfl.ai/v1" + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + # Map model to endpoint + if "pro" in model: + return f"{self.BASE_URL}/flux-pro-1.1" + elif "dev" in model: + return f"{self.BASE_URL}/flux-dev" + else: + return f"{self.BASE_URL}/{model}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + size = str(body.get("size") or "1792x1024") + + # BFL uses width/height + from services.image_providers._base import size_to_width_height + w, h = size_to_width_height(size) + + return { + "prompt": prompt, + "width": w, + "height": h, + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + api_key = "" + if credentials and isinstance(credentials, dict): + api_key = str(credentials.get("apiKey") or credentials.get("accessToken") or "") + return { + "X-Key": api_key, + "Content-Type": "application/json", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Submit and poll for result.""" + if not hasattr(response, "json"): + return None + + data = response.json() + task_id = data.get("id") + if not task_id: + return None + + # Poll for result + elapsed = 0.0 + while elapsed < POLL_TIMEOUT_S: + sleep_s(POLL_INTERVAL_S) + elapsed += POLL_INTERVAL_S + + try: + poll_resp = requests.get( + f"{self.BASE_URL}/get_result", + params={"id": task_id}, + timeout=30, + ) + poll_data = poll_resp.json() + except Exception as exc: + logger.warning({"event": "bfl_poll_error", "error": str(exc)}) + continue + + status = poll_data.get("status", "") + if status == "Ready": + result = poll_data.get("result", {}) + sample_url = result.get("sample") + if sample_url: + from services.image_providers._base import url_to_base64 + return {"data": [{"b64_json": url_to_base64(sample_url)}]} + + elif status in ("Error", "Failed"): + logger.error({"event": "bfl_failed", "status": status}) + return None + + logger.error({"event": "bfl_timeout", "elapsed": elapsed}) + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + data = parsed.get("data") or [] + return {"created": now_sec(), "data": data} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get("https://api.bfl.ai/v1", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +bfl_adapter = BFLAdapter() diff --git a/services/image_providers/cloudflare.py b/services/image_providers/cloudflare.py new file mode 100644 index 00000000..94e9e2e5 --- /dev/null +++ b/services/image_providers/cloudflare.py @@ -0,0 +1,80 @@ +""" +Cloudflare Workers AI Adapter — port from 9router cloudflareAi.js. + +Free tier available with Cloudflare account. +Models: @cf/black-forest-labs/flux-1-schnell, @cf/bytedance/stable-diffusion-xl-lightning +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import BaseImageAdapter, now_sec +from utils.log import logger + + +class CloudflareAIAdapter(BaseImageAdapter): + """Cloudflare Workers AI adapter. + + Requires Cloudflare Account ID + API Token (free tier available). + Model format: cloudflare/@cf/black-forest-labs/flux-1-schnell + """ + + BASE_URL = "https://api.cloudflare.com/client/v4/accounts" + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + account_id = "" + if credentials and isinstance(credentials, dict): + account_id = str(credentials.get("accountId") or credentials.get("account_id") or "") + return f"{self.BASE_URL}/{account_id}/ai/run/{model}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + n = max(1, min(4, int(body.get("n") or 1))) + return { + "prompt": prompt, + "num_steps": 4 if "schnell" in model else 8, + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + api_token = "" + if credentials and isinstance(credentials, dict): + api_token = str(credentials.get("apiToken") or credentials.get("api_token") or credentials.get("accessToken") or "") + return { + "Authorization": f"Bearer {api_token}", + "Content-Type": "application/json", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + # Cloudflare returns {"result": {"image": "base64..."}} + if hasattr(response, "json"): + data = response.json() + result = data.get("result", {}) + if isinstance(result, dict) and result.get("image"): + return {"image_base64": result["image"]} + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + b64 = parsed.get("image_base64") + if b64 and isinstance(b64, str): + return {"created": now_sec(), "data": [{"b64_json": b64}]} + return {"created": now_sec(), "data": []} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get("https://api.cloudflare.com/client/v4/user/tokens/verify", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +cloudflare_adapter = CloudflareAIAdapter() diff --git a/services/image_providers/custom_openai_image.py b/services/image_providers/custom_openai_image.py new file mode 100644 index 00000000..7b89574e --- /dev/null +++ b/services/image_providers/custom_openai_image.py @@ -0,0 +1,145 @@ +""" +Custom OpenAI-compatible Image Adapter — uses chat endpoint for image generation. + +For custom providers that support image gen via their chat API (e.g., Gemini API +server with /v1/responses or built-in image generation tools). +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import ( + BaseImageAdapter, + now_sec, + size_to_width_height, +) +from services.config import config +from utils.log import logger + + +class CustomOpenAIImageAdapter(BaseImageAdapter): + """Generic image adapter for custom providers — uses chat endpoint.""" + + def __init__(self, provider_id: str): + self.provider_id = provider_id + + def _get_provider_config(self) -> dict[str, Any] | None: + providers = config.data.get("custom_providers") or {} + return providers.get(self.provider_id) + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + cfg = self._get_provider_config() + base_url = str(cfg.get("base_url") or "").rstrip("/") if cfg else "" + return f"{base_url}/v1/chat/completions" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + size = str(body.get("size") or "1792x1024") + w, h = size_to_width_height(size) + + return { + "model": model, + "messages": [{ + "role": "user", + "content": ( + f"Generate an image based on this description: {prompt}\n" + f"Size: {w}x{h}\n" + f"Return the image as a base64 data URL." + ), + }], + "max_tokens": 4096, + "temperature": 0.9, + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + cfg = self._get_provider_config() + api_key = "" + if cfg: + keys = cfg.get("api_keys") or [] + if not keys: + api_key = str(cfg.get("api_key") or "") + else: + api_key = keys[0] + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Parse chat response to extract generated image (base64).""" + if not hasattr(response, "json"): + return None + + try: + data = response.json() + except Exception as exc: + logger.error({"event": "custom_image_parse_error", "error": str(exc)}) + return None + + choices = data.get("choices") or [] + for choice in choices: + content = choice.get("message", {}).get("content") or "" + if not content: + continue + + # Try to extract base64 image from response + import re + # Match data:image/...;base64,... + match = re.search(r'data:image/[^;]+;base64,([A-Za-z0-9+/=]+)', content) + if match: + return {"data": [{"b64_json": match.group(1)}]} + + # Maybe the entire response is a base64 image + if content.startswith('/9j/') or content.startswith('iVBOR'): + return {"data": [{"b64_json": content}]} + + # If the response contains a URL to an image + url_match = re.search(r'https?://[^\s"]+\.(?:png|jpg|jpeg|webp)[^\s"]*', content) + if url_match: + from services.image_providers._base import url_to_base64 + try: + b64 = url_to_base64(url_match.group(0)) + return {"data": [{"b64_json": b64}]} + except Exception: + pass + + logger.warning({"event": "custom_image_no_data", "provider": self.provider_id}) + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + data = parsed.get("data") or [] + normalized_data = [] + for item in data: + b64 = item.get("b64_json") or "" + if b64 and not b64.startswith("data:"): + b64 = f"data:image/png;base64,{b64}" + if b64: + normalized_data.append({"b64_json": b64, "revised_prompt": str(body.get("prompt") or "")}) + return {"created": now_sec(), "data": normalized_data} if normalized_data else {"created": now_sec(), "data": []} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + cfg = self._get_provider_config() + if not cfg: + return False + base_url = str(cfg.get("base_url") or "").rstrip("/") + keys = cfg.get("api_keys") or [str(cfg.get("api_key") or "")] + api_key = keys[0] if keys else "" + try: + resp = requests.get( + f"{base_url}/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10, + ) + return resp.status_code == 200 + except Exception: + return False diff --git a/services/image_providers/fal_ai.py b/services/image_providers/fal_ai.py new file mode 100644 index 00000000..74c5c74f --- /dev/null +++ b/services/image_providers/fal_ai.py @@ -0,0 +1,133 @@ +""" +Fal.ai Adapter — port from 9router falAi.js. + +Async (polling-based) adapter for Fal.ai queue API. +Model format: fal_ai/fal-ai/flux/schnell +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import ( + BaseImageAdapter, + POLL_INTERVAL_S, + POLL_TIMEOUT_S, + now_sec, + sleep_s, +) +from utils.log import logger + + +class FalAIAdapter(BaseImageAdapter): + """Fal.ai async queue adapter. + + Submits to queue API, polls status_url until completion. + """ + + BASE_URL = "https://queue.fal.run" + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + return f"{self.BASE_URL}/{model}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + size = str(body.get("size") or "1792x1024") + + # Map OpenAI size to fal.ai image_size + size_map = { + "1024x1024": "square_hd", + "1792x1024": "landscape_16_9", + "1024x1792": "portrait_16_9", + "1280x896": "landscape_4_3", + "896x1280": "portrait_4_3", + } + + return { + "prompt": prompt, + "image_size": size_map.get(size, "landscape_16_9"), + "num_images": max(1, min(4, int(body.get("n") or 1))), + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + api_key = "" + if credentials and isinstance(credentials, dict): + api_key = str(credentials.get("apiKey") or credentials.get("accessToken") or "") + return { + "Authorization": f"Key {api_key}", + "Content-Type": "application/json", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Submit to queue, poll for result.""" + if not hasattr(response, "json"): + return None + + data = response.json() + status_url = data.get("status_url") + if not status_url: + logger.error({"event": "fal_ai_no_status_url", "response": str(data)[:200]}) + return None + + # Poll for completion + elapsed = 0.0 + while elapsed < POLL_TIMEOUT_S: + sleep_s(POLL_INTERVAL_S) + elapsed += POLL_INTERVAL_S + + try: + poll_resp = requests.get(status_url, timeout=30) + poll_data = poll_resp.json() + except Exception as exc: + logger.warning({"event": "fal_ai_poll_error", "error": str(exc)}) + continue + + status = poll_data.get("status", "") + if status == "COMPLETED": + result = poll_data.get("result") or poll_data + images = ( + result.get("images") or + [result.get("image")] if result.get("image") else + [] + ) + if images: + b64_list = [] + for img in images: + if isinstance(img, dict) and img.get("url"): + from services.image_providers._base import url_to_base64 + b64_list.append({"b64_json": url_to_base64(img["url"])}) + return {"data": b64_list} + + logger.error({"event": "fal_ai_no_images", "result": str(result)[:200]}) + return None + + elif status in ("FAILED", "CANCELLED"): + error_msg = str(poll_data.get("error") or "unknown") + logger.error({"event": "fal_ai_failed", "status": status, "error": error_msg}) + return None + + logger.error({"event": "fal_ai_timeout", "elapsed": elapsed}) + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + data = parsed.get("data") or [] + return {"created": now_sec(), "data": data} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get("https://queue.fal.run", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +fal_ai_adapter = FalAIAdapter() diff --git a/services/image_providers/gemini_image.py b/services/image_providers/gemini_image.py new file mode 100644 index 00000000..19bc0728 --- /dev/null +++ b/services/image_providers/gemini_image.py @@ -0,0 +1,144 @@ +""" +Gemini Image Adapter — port from 9router gemini.js. + +Google Gemini image generation via Imagen. +Uses Gemini API: https://generativelanguage.googleapis.com/v1beta/models/ +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import BaseImageAdapter, now_sec +from utils.log import logger + + +class GeminiImageAdapter(BaseImageAdapter): + """Gemini Imagen image generation adapter. + + Model format: gemini-image/imagen-3.0-generate-001 + Uses Gemini generateContent API with image generation config. + Supports API key rotation from api_keys array. + """ + + BASE_URL = "https://generativelanguage.googleapis.com/v1beta/models" + _key_index: int = 0 + + def _get_api_keys(self, credentials: dict[str, Any] | None) -> list[str]: + """Get all available API keys from credentials.""" + if not credentials or not isinstance(credentials, dict): + return [] + keys = credentials.get("apiKeys") or credentials.get("api_keys") or [] + if isinstance(keys, list) and keys: + return [str(k) for k in keys if k] + single = str(credentials.get("apiKey") or credentials.get("api_key") or "") + return [single] if single else [] + + def build_url(self, model: str, credentials: dict[str, Any] | None, key_index: int = 0) -> str: + api_key = "" + if credentials and isinstance(credentials, dict): + keys = self._get_api_keys(credentials) + if keys: + api_key = keys[key_index % len(keys)] + return f"{self.BASE_URL}/{model}:generateContent?key={api_key}" + + def get_key_count(self, credentials: dict[str, Any] | None) -> int: + return len(self._get_api_keys(credentials)) + + # Size → aspect ratio mapping (OpenAI format → Gemini format) + _SIZE_TO_RATIO = { + # 16:9 + "1792x1024": "16:9", "1344x768": "16:9", + # 9:16 + "1024x1792": "9:16", "768x1344": "9:16", + # 1:1 + "1024x1024": "1:1", "768x768": "1:1", "512x512": "1:1", "256x256": "1:1", + # 4:3 + "1792x1344": "4:3", "1200x896": "4:3", "1024x768": "4:3", + # 3:2 + "1536x1024": "3:2", "1264x848": "3:2", + # 3:4 + "768x1024": "3:4", "896x1200": "3:4", + } + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + images = body.get("images") or [] + n = max(1, min(4, int(body.get("n") or 1))) + size = str(body.get("size") or "") + + parts = [{"text": prompt}] + for img in images: + if isinstance(img, bytes): + import base64 as b64 + parts.append({"inlineData": {"mimeType": "image/png", "data": b64.b64encode(img).decode()}}) + elif isinstance(img, str) and img.startswith("data:"): + header, data = img.split(",", 1) + mime = header.split(";")[0].replace("data:", "") + parts.append({"inlineData": {"mimeType": mime, "data": data}}) + + gen_config: dict[str, Any] = { + "responseModalities": ["TEXT", "IMAGE"], + } + + # Note: generateContent does NOT support responseFormat. + # Aspect ratio and image size are controlled via model-specific parameters + # that vary by model version. Default is model-dependent. + + return { + "contents": [{"parts": parts}], + "generationConfig": gen_config, + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + return {"Content-Type": "application/json"} + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Extract inline image data from Gemini response.""" + if not hasattr(response, "json"): + return None + + data = response.json() + + # Check for error + if "error" in data: + err = data["error"] + raise RuntimeError(f"Gemini API error {err.get('status','')}: {err.get('message','')[:200]}") + + images = [] + + candidates = data.get("candidates") or [] + for candidate in candidates: + content = candidate.get("content") or {} + parts = content.get("parts") or [] + for part in parts: + if "inlineData" in part: + inline = part["inlineData"] + b64 = inline.get("data") or "" + if b64: + images.append({"b64_json": b64}) + + return {"data": images} if images else None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + data = parsed.get("data") or [] + return {"created": now_sec(), "data": data} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get("https://generativelanguage.googleapis.com", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +gemini_image_adapter = GeminiImageAdapter() diff --git a/services/image_providers/huggingface.py b/services/image_providers/huggingface.py new file mode 100644 index 00000000..41e93be6 --- /dev/null +++ b/services/image_providers/huggingface.py @@ -0,0 +1,70 @@ +""" +HuggingFace Inference API Adapter — port from 9router huggingface.js. + +Free tier available for many models (e.g., black-forest-labs/FLUX.1-schnell). +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import BaseImageAdapter, now_sec +from utils.log import logger + + +class HuggingFaceAdapter(BaseImageAdapter): + """HuggingFace Inference API adapter. + + Supports free-tier models with optional API token. + Model format: huggingface/owner/model-name + """ + + BASE_URL = "https://api-inference.huggingface.co/models" + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + return f"{self.BASE_URL}/{model}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + return {"inputs": prompt} + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + headers = {"Content-Type": "application/json"} + api_key = "" + if credentials and isinstance(credentials, dict): + api_key = str(credentials.get("apiKey") or credentials.get("accessToken") or "") + if api_key: + headers["Authorization"] = f"Bearer {api_key}" + return headers + + def parse_response(self, response: Any) -> dict[str, Any] | None: + # HuggingFace returns raw image bytes + if hasattr(response, "content") and response.headers.get("content-type", "").startswith("image/"): + return {"image_bytes": response.content} + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + image_bytes = parsed.get("image_bytes") + if image_bytes and isinstance(image_bytes, bytes): + b64 = base64.b64encode(image_bytes).decode("ascii") + return {"created": now_sec(), "data": [{"b64_json": b64}]} + return {"created": now_sec(), "data": []} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get(f"{self.BASE_URL}/black-forest-labs/FLUX.1-schnell", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +huggingface_adapter = HuggingFaceAdapter() diff --git a/services/image_providers/nvidia_nim_image.py b/services/image_providers/nvidia_nim_image.py new file mode 100644 index 00000000..18e7621e --- /dev/null +++ b/services/image_providers/nvidia_nim_image.py @@ -0,0 +1,159 @@ +""" +NVIDIA NIM Image Generation Adapter. + +Endpoint: https://ai.api.nvidia.com/v1/genai/{model} +Auth: Bearer token from build.nvidia.com +Format: Custom request/response — needs conversion to/from OpenAI format. + +Example model: black-forest-labs/flux.2-klein-4b +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import ( + BaseImageAdapter, + now_sec, + size_to_width_height, +) +from services.config import config +from utils.log import logger + + +class NvidiaNimImageAdapter(BaseImageAdapter): + """NVIDIA NIM Image Generation adapter. + + Uses NVIDIA's image generation endpoint (different from chat endpoint). + """ + + BASE_URL = "https://ai.api.nvidia.com/v1/genai" + + def _get_keys(self) -> list[str]: + cfg = config.data.get("providers") or {} + nv_cfg = cfg.get("nvidia_nim") or {} + single = str(nv_cfg.get("api_key") or "").strip() + multi = nv_cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + return f"{self.BASE_URL}/{model}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + size = str(body.get("size") or "1792x1024") + w, h = size_to_width_height(size) + + return { + "prompt": prompt, + "width": w, + "height": h, + "seed": body.get("seed", 0), + "steps": body.get("steps", 4), + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + # Use API key from provider config + keys = self._get_keys() + api_key = keys[0] if keys else "" + if credentials and isinstance(credentials, dict): + api_key = str(credentials.get("apiKey") or credentials.get("accessToken") or api_key) + return { + "Authorization": f"Bearer {api_key}", + "Content-Type": "application/json", + "Accept": "application/json", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + """Parse NVIDIA image gen response → OpenAI image format.""" + if not hasattr(response, "json"): + return None + + try: + data = response.json() + except Exception as exc: + logger.error({"event": "nvidia_image_parse_error", "error": str(exc)}) + return None + + # Response format: {"artifacts":[{"base64":"..."}]} or {"image":"..."} + image_b64 = "" + + # NVIDIA returns artifacts array with base64 + artifacts = data.get("artifacts") or [] + if isinstance(artifacts, list) and artifacts: + first = artifacts[0] + if isinstance(first, dict): + image_b64 = first.get("base64") or first.get("image") or "" + elif isinstance(first, str): + image_b64 = first + + if not image_b64: + # Try direct image field + image_b64 = data.get("image") or "" + + if not image_b64: + images = data.get("images") or data.get("data") or [] + if isinstance(images, list) and images: + first = images[0] + if isinstance(first, dict): + image_b64 = first.get("image") or first.get("b64_json") or first.get("url") or first.get("base64") or "" + elif isinstance(first, str): + image_b64 = first + + if not image_b64: + logger.error({"event": "nvidia_image_no_data", "keys": list(data.keys())[:5]}) + return None + + # If it's a URL, convert to base64 + if image_b64.startswith("http"): + from services.image_providers._base import url_to_base64 + image_b64 = url_to_base64(image_b64) + + return {"data": [{"b64_json": image_b64}]} + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + """Convert parsed result → OpenAI image response format.""" + data = parsed.get("data") or [] + normalized_data = [] + for item in data: + b64 = item.get("b64_json") or "" + if b64: + if not b64.startswith("data:"): + b64 = f"data:image/png;base64,{b64}" + normalized_data.append({"b64_json": b64, "revised_prompt": str(body.get("prompt") or "")}) + + if not normalized_data: + return {"created": now_sec(), "data": []} + + return {"created": now_sec(), "data": normalized_data} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + """Test connectivity to NVIDIA image gen endpoint.""" + try: + keys = self._get_keys() + api_key = keys[0] if keys else "" + resp = requests.get( + "https://integrate.api.nvidia.com/v1/models", + headers={"Authorization": f"Bearer {api_key}"}, + timeout=10, + ) + return resp.status_code == 200 + except Exception: + return False + + +nvidia_nim_image_adapter = NvidiaNimImageAdapter() diff --git a/services/image_providers/sdwebui.py b/services/image_providers/sdwebui.py new file mode 100644 index 00000000..f39b3a57 --- /dev/null +++ b/services/image_providers/sdwebui.py @@ -0,0 +1,78 @@ +""" +SD WebUI Adapter — port from 9router sdwebui.js. + +AUTOMATIC1111 Stable Diffusion Web UI at localhost:7860. +NoAuth — completely free, runs on local GPU. +""" + +from __future__ import annotations + +import base64 +import json +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import BaseImageAdapter, now_sec, size_to_width_height +from utils.log import logger + + +class SDWebUIAdapter(BaseImageAdapter): + """Stable Diffusion Web UI (AUTOMATIC1111) adapter. + + NoAuth — runs locally at http://localhost:7860. + """ + + no_auth = True + + def __init__(self, base_url: str = "http://localhost:7860"): + self.base_url = base_url.rstrip("/") + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + return f"{self.base_url}/sdapi/v1/txt2img" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + n = max(1, min(4, int(body.get("n") or 1))) + size = str(body.get("size") or "1792x1024") + w, h = size_to_width_height(size) + + return { + "prompt": prompt, + "negative_prompt": "", + "width": w, + "height": h, + "steps": 20, + "batch_size": n, + "cfg_scale": 7, + "sampler_name": "Euler a", + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + return {"Content-Type": "application/json"} + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + # SD WebUI returns {"images": ["base64...", ...]} + images = parsed.get("images") or [] + data = [ + {"b64_json": img} + for img in images + if isinstance(img, str) + ] + return {"created": now_sec(), "data": data} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get(f"{self.base_url}/sdapi/v1/sd-models", timeout=5) + return resp.status_code == 200 + except Exception: + return False + + +sdwebui_adapter = SDWebUIAdapter() diff --git a/services/image_providers/stability.py b/services/image_providers/stability.py new file mode 100644 index 00000000..7dbc6524 --- /dev/null +++ b/services/image_providers/stability.py @@ -0,0 +1,91 @@ +""" +Stability AI Adapter — port from 9router stabilityAi.js. + +Stability AI v2 API: https://api.stability.ai/v2beta/stable-image/generate/ +Models: ultra, sd3, core +""" + +from __future__ import annotations + +import base64 +from typing import Any + +from curl_cffi import requests + +from services.image_providers._base import BaseImageAdapter, now_sec +from utils.log import logger + + +class StabilityAIAdapter(BaseImageAdapter): + """Stability AI v2 adapter. + + Model format: stability/sd3, stability/ultra, stability/core + """ + + BASE_URL = "https://api.stability.ai/v2beta/stable-image/generate" + + # Model name → API endpoint + ENDPOINT_MAP = { + "ultra": "ultra", + "sd3": "sd3", + "core": "core", + } + + def build_url(self, model: str, credentials: dict[str, Any] | None) -> str: + endpoint = self.ENDPOINT_MAP.get(model, "sd3") + return f"{self.BASE_URL}/{endpoint}" + + def build_body(self, model: str, body: dict[str, Any]) -> dict[str, Any]: + prompt = str(body.get("prompt") or "") + size = str(body.get("size") or "1792x1024") + + aspect_ratio_map = { + "1024x1024": "1:1", + "1792x1024": "16:9", + "1024x1792": "9:16", + "1280x896": "4:3", + "896x1280": "3:4", + } + + return { + "prompt": prompt, + "aspect_ratio": aspect_ratio_map.get(size, "16:9"), + "output_format": "png", + } + + def build_headers( + self, + credentials: dict[str, Any] | None, + request_body: dict[str, Any], + model: str, + body: dict[str, Any], + ) -> dict[str, str]: + api_key = "" + if credentials and isinstance(credentials, dict): + api_key = str(credentials.get("apiKey") or credentials.get("accessToken") or "") + return { + "Authorization": f"Bearer {api_key}", + "Accept": "image/*", + } + + def parse_response(self, response: Any) -> dict[str, Any] | None: + if hasattr(response, "content") and response.headers.get("content-type", "").startswith("image/"): + return {"image_bytes": response.content} + return None + + def normalize(self, parsed: dict[str, Any], body: dict[str, Any]) -> dict[str, Any]: + image_bytes = parsed.get("image_bytes") + if image_bytes and isinstance(image_bytes, bytes): + b64 = base64.b64encode(image_bytes).decode("ascii") + return {"created": now_sec(), "data": [{"b64_json": b64}]} + return {"created": now_sec(), "data": []} + + def test_connection(self, credentials: dict[str, Any] | None = None) -> bool: + try: + resp = requests.get("https://api.stability.ai", timeout=10) + return resp.status_code < 500 + except Exception: + return False + + +stability_adapter = StabilityAIAdapter() diff --git a/services/image_providers/veo_video.py b/services/image_providers/veo_video.py new file mode 100644 index 00000000..88da1a50 --- /dev/null +++ b/services/image_providers/veo_video.py @@ -0,0 +1,194 @@ +""" +Veo Video Adapter — Google Veo 3.1 video generation. + +Endpoint: :predictLongRunning (async operation with polling) +Model: veo-3.1-generate-preview +Supports: text→video, image→video, video extension, reference images +""" + +from __future__ import annotations + +import base64 +import time +from typing import Any + +from curl_cffi import requests + +from utils.log import logger + +VEO_BASE = "https://generativelanguage.googleapis.com/v1beta/models" +VEO_MODEL = "veo-3.1-generate-preview" +VEO_POLL_INTERVAL = 10 # seconds +VEO_MAX_WAIT = 600 # 10 minutes max + + +class VeoVideoAdapter: + """Google Veo 3.1 video generation adapter. + + Model format: veo/veo-3.1-generate-preview + Uses Veo predictLongRunning API with operation polling. + """ + + def __init__(self): + self._key_index = 0 + + def _get_api_keys(self, credentials: dict[str, Any] | None) -> list[str]: + if not credentials or not isinstance(credentials, dict): + return [] + keys = credentials.get("apiKeys") or credentials.get("api_keys") or [] + if isinstance(keys, list) and keys: + return [str(k) for k in keys if k] + single = str(credentials.get("apiKey") or credentials.get("api_key") or "") + return [single] if single else [] + + def get_key_count(self, credentials: dict[str, Any] | None) -> int: + return len(self._get_api_keys(credentials)) + + def _build_url(self, credentials: dict[str, Any] | None, key_index: int = 0) -> str: + keys = self._get_api_keys(credentials) + api_key = keys[key_index % len(keys)] if keys else "" + return f"{VEO_BASE}/{VEO_MODEL}:predictLongRunning?key={api_key}" + + def _build_body(self, body: dict[str, Any]) -> dict[str, Any]: + """Build Veo API request body.""" + prompt = str(body.get("prompt") or "") + instance: dict[str, Any] = {"prompt": prompt} + + # Optional: input image for image→video + image_b64 = body.get("image") + if image_b64: + instance["image"] = { + "inlineData": {"mimeType": "image/png", "data": image_b64} + } + + # Optional: last frame for interpolation + last_frame = body.get("last_frame") + if last_frame: + instance["lastFrame"] = { + "inlineData": {"mimeType": "image/png", "data": last_frame} + } + + request: dict[str, Any] = {"instances": [instance]} + + # Parameters + params: dict[str, Any] = {} + aspect_ratio = body.get("aspect_ratio") or "16:9" + if aspect_ratio: + params["aspectRatio"] = aspect_ratio + + duration = body.get("duration") + if duration: + params["durationSeconds"] = duration + + resolution = body.get("resolution") + if resolution: + params["resolution"] = resolution + + if params: + request["parameters"] = params + + return request + + def generate( + self, + body: dict[str, Any], + credentials: dict[str, Any] | None, + ) -> dict[str, Any]: + """Generate video — submit, poll, download. + + Returns: + {"data": [{"b64_json": ""}]} + """ + max_keys = self.get_key_count(credentials) + last_error = "" + + for key_try in range(max(max_keys, 1)): + try: + url = self._build_url(credentials, key_try) + req_body = self._build_body(body) + + logger.info({ + "event": "veo_request", + "url": url[:120], + "key_try": key_try, + }) + + # Step 1: Submit + resp = requests.post(url, json=req_body, timeout=60) + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + if resp.status_code in (400, 429) and key_try < max_keys - 1: + last_error = error_text + continue + raise RuntimeError(f"Veo submit error {resp.status_code}: {error_text[:200]}") + + data = resp.json() + operation_name = data.get("name", "") + if not operation_name: + raise RuntimeError(f"Veo did not return operation name: {data}") + + logger.info({"event": "veo_submitted", "operation": operation_name[:80]}) + + # Step 2: Poll until done + base_url = f"https://generativelanguage.googleapis.com/v1beta/{operation_name}" + keys = self._get_api_keys(credentials) + api_key = keys[key_index % len(keys)] if keys else "" + + start_time = time.time() + while time.time() - start_time < VEO_MAX_WAIT: + time.sleep(VEO_POLL_INTERVAL) + poll_resp = requests.get(f"{base_url}?key={api_key}", timeout=30) + if poll_resp.status_code >= 400: + continue # keep polling + poll_data = poll_resp.json() + if poll_data.get("done"): + # Step 3: Extract video URI and download + video_uri = ( + poll_data.get("response", {}) + .get("generateVideoResponse", {}) + .get("generatedSamples", [{}])[0] + .get("video", {}) + .get("uri", "") + ) + if not video_uri: + raise RuntimeError("Veo completed but no video URI") + + logger.info({"event": "veo_downloading", "uri": video_uri[:120]}) + + dl_resp = requests.get( + f"{video_uri}?key={api_key}", + timeout=120, + ) + if dl_resp.status_code == 200: + video_b64 = base64.b64encode(dl_resp.content).decode() + return { + "created": int(time.time()), + "data": [{"b64_json": video_b64}], + } + + raise RuntimeError(f"Veo download error {dl_resp.status_code}") + + logger.info({ + "event": "veo_polling", + "operation": operation_name[:40], + "elapsed": int(time.time() - start_time), + }) + + raise RuntimeError(f"Veo timed out after {VEO_MAX_WAIT}s") + + except RuntimeError: + raise + except Exception as exc: + logger.error({"event": "veo_error", "error": str(exc)}) + if key_try < max_keys - 1: + continue + raise RuntimeError(f"Veo generation failed: {exc}") from exc + + raise RuntimeError(f"Veo generation failed: {last_error}") + + +veo_adapter = VeoVideoAdapter() diff --git a/services/image_task_service.py b/services/image_task_service.py index 69a178d1..745a7547 100644 --- a/services/image_task_service.py +++ b/services/image_task_service.py @@ -238,7 +238,7 @@ def _run_task( mode, model, started, - "调用完成", + "Gọi thành công", request_preview=request_text(payload.get("prompt")), urls=_collect_image_urls(data), ) @@ -250,7 +250,7 @@ def _run_task( mode, model, started, - "调用失败", + "Gọi thất bại", request_preview=request_text(payload.get("prompt")), status="failed", error=error_message, diff --git a/services/karpathy_guidelines.py b/services/karpathy_guidelines.py new file mode 100644 index 00000000..e40078cb --- /dev/null +++ b/services/karpathy_guidelines.py @@ -0,0 +1,75 @@ +"""Karpathy Guidelines for AI coding behavior. + +Loads guidelines from cache, with hardcoded fallback. +Call refresh_guidelines() to fetch latest from upstream GitHub. +""" + +import os +import requests + +KARPATHY_URL = ( + "https://raw.githubusercontent.com/forrestchang/" + "andrej-karpathy-skills/main/CLAUDE.md" +) +CACHE_PATH = os.path.join( + os.path.dirname(__file__), "..", "data", "karpathy_cache.md" +) + +HARDCODED_FALLBACK = """\ +## Core Principles for AI Coding + +### 1. Think Before Coding +- State assumptions explicitly before coding +- If uncertain, ask — don't guess +- Present tradeoffs when multiple approaches exist +- When confused, stop and name what's unclear + +### 2. Simplicity First +- Minimum code that solves the problem +- No abstractions for single-use code +- No speculative flexibility or configurability +- If 200 lines could be 50, rewrite it + +### 3. Surgical Changes +- Touch only what you must +- Don't "improve" adjacent code, comments, or formatting +- Match existing style +- Every changed line must trace to the user's request + +### 4. Goal-Driven Execution +- Define success criteria before coding +- Test first: write test, fix, verify +- For multi-step tasks: brief plan + verify each step\ +""" + + +def load_guidelines() -> str: + """Return current guidelines — cache preferred, hardcoded fallback.""" + try: + if os.path.exists(CACHE_PATH): + with open(CACHE_PATH, encoding="utf-8") as f: + content = f.read() + if len(content) > 100: + return content + except Exception: + pass + return HARDCODED_FALLBACK + + +def refresh_guidelines() -> bool: + """Fetch latest from GitHub. Return True if cache was updated.""" + try: + r = requests.get(KARPATHY_URL, timeout=15) + if r.status_code == 200 and len(r.text) > 100: + os.makedirs(os.path.dirname(CACHE_PATH), exist_ok=True) + old = "" + if os.path.exists(CACHE_PATH): + with open(CACHE_PATH, encoding="utf-8") as f: + old = f.read() + if r.text.strip() != old.strip(): + with open(CACHE_PATH, "w", encoding="utf-8") as f: + f.write(r.text) + return True + except Exception: + pass + return False diff --git a/services/log_service.py b/services/log_service.py index 63d04d5d..5a5c6b7b 100644 --- a/services/log_service.py +++ b/services/log_service.py @@ -188,33 +188,33 @@ async def run(self, handler, *args, sse: str = "openai"): try: result = await run_in_threadpool(handler, *args) except ImageGenerationError as exc: - self.log("调用失败", status="failed", error=str(exc)) + self.log("Gọi thất bại", status="failed", error=str(exc)) return _image_error_response(exc) except HTTPException as exc: - self.log("调用失败", status="failed", error=str(exc.detail)) + self.log("Gọi thất bại", status="failed", error=str(exc.detail)) raise except Exception as exc: - self.log("调用失败", status="failed", error=str(exc)) + self.log("Gọi thất bại", status="failed", error=str(exc)) raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc if isinstance(result, dict): - self.log("调用完成", result) + self.log("Gọi thành công", result) return result 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)) + self.log("Gọi thất bại", status="failed", error=str(exc)) return _image_error_response(exc) except HTTPException as exc: - self.log("调用失败", status="failed", error=str(exc.detail)) + self.log("Gọi thất bại", status="failed", error=str(exc.detail)) raise except Exception as exc: - self.log("调用失败", status="failed", error=str(exc)) + self.log("Gọi thất bại", status="failed", error=str(exc)) raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc if not has_first: - self.log("流式调用结束") + self.log("Kết thúc stream") return StreamingResponse(sender(()), media_type="text/event-stream") return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream") @@ -227,11 +227,11 @@ def stream(self, items): yield item except Exception as exc: failed = True - self.log("流式调用失败", status="failed", error=str(exc), urls=urls) + self.log("流式Gọi thất bại", status="failed", error=str(exc), urls=urls) raise finally: if not failed: - self.log("流式调用结束", urls=urls) + self.log("Kết thúc stream", urls=urls) def log(self, suffix: str, result: object = None, status: str = "success", error: str = "", urls: list[str] | None = None) -> None: diff --git a/services/model_cooldown.py b/services/model_cooldown.py new file mode 100644 index 00000000..f647d4e7 --- /dev/null +++ b/services/model_cooldown.py @@ -0,0 +1,335 @@ +""" +Model Cooldown — per-model rate-limit state tracking. + +Ports the per-model cooldown + structured error pattern from CLIProxyAPI: +- ModelState per (account, model) — a token can be blocked for model A but fine for B +- Aggregate check — when ALL tokens for a model are cooling, return structured 429 +- Provider retry-after parsing — extract reset hints from Codex/Gemini error bodies +""" + +from __future__ import annotations + +import json +import re +import time +from dataclasses import dataclass, field +from typing import Any + +from services.account_service import account_service +from utils.log import logger + + +@dataclass +class ModelState: + """Per-account, per-model cooldown state (like CLIProxyAPI's ModelState).""" + model: str + status: str = "active" # active | cooldown | blocked | disabled + next_retry_at: float = 0.0 # Unix timestamp + backoff_level: int = 0 + reason: str = "" # quota | unauthorized | payment_required | server_error + last_error: str = "" + + @property + def is_cooling(self) -> bool: + return self.status == "cooldown" and time.time() < self.next_retry_at + + @property + def remaining_seconds(self) -> float: + if self.status != "cooldown": + return 0 + return max(0, self.next_retry_at - time.time()) + + +class ModelCooldownManager: + """Tracks per-model cooldown across all accounts. + + Like CLIProxyAPI's authScheduler + modelScheduler combined. + """ + + BACKOFF_BASE = 1.0 # seconds + BACKOFF_MAX = 1800.0 # 30 minutes + BACKOFF_401_COOLDOWN = 1800.0 # 30 min + BACKOFF_402_403_COOLDOWN = 1800.0 + BACKOFF_404_COOLDOWN = 43200.0 # 12 hours + BACKOFF_5XX_COOLDOWN = 60.0 # 1 minute + BACKOFF_UNKNOWN_COOLDOWN = 60.0 + + def __init__(self): + # {account_token: {model: ModelState}} + self._states: dict[str, dict[str, ModelState]] = {} + + # ── Public API ────────────────────────────────────────────── + + def record_success(self, account_id: str, model: str): + """Clear cooldown for a model on success (fast recovery).""" + states = self._states.get(account_id, {}) + state = states.get(model) + if state: + state.status = "active" + state.backoff_level = max(0, state.backoff_level - 2) + state.next_retry_at = 0 + state.reason = "" + + def record_failure( + self, + account_id: str, + model: str, + status_code: int = 0, + error_body: str = "", + provider: str = "", + ) -> ModelState: + """Record a failure and return the updated ModelState. + + Args: + account_id: Account identifier (token hash) + model: Model that failed + status_code: HTTP status code from upstream + error_body: Error response body text + provider: Provider key (codex, gemini, etc.) + """ + acc_key = account_id or "unknown" + mdl_key = (model or "default").strip() + states = self._states.setdefault(acc_key, {}) + state = states.get(mdl_key) + if state is None: + state = ModelState(model=mdl_key) + states[mdl_key] = state + + # Try provider-reported retry-after first + provider_retry = _parse_provider_retry_after(status_code, error_body, provider) + if provider_retry: + cooldown = min(provider_retry, self.BACKOFF_MAX) + state.status = "cooldown" + state.next_retry_at = time.time() + cooldown + state.reason = "quota" + state.last_error = error_body[:200] + return state + + # Classify by status code + if status_code == 401: + cooldown = self.BACKOFF_401_COOLDOWN + reason = "unauthorized" + elif status_code in (402, 403): + cooldown = self.BACKOFF_402_403_COOLDOWN + reason = "payment_required" + elif status_code == 404: + cooldown = self.BACKOFF_404_COOLDOWN + reason = "not_found" + elif status_code == 429: + cooldown = self._exponential_backoff(state.backoff_level) + state.backoff_level += 1 + reason = "quota" + elif status_code >= 500: + cooldown = self.BACKOFF_5XX_COOLDOWN + reason = "server_error" + elif _is_quota_exceeded(error_body): + cooldown = self._exponential_backoff(state.backoff_level) + state.backoff_level += 1 + reason = "quota" + else: + cooldown = self.BACKOFF_UNKNOWN_COOLDOWN + reason = "unknown" + + state.status = "cooldown" + state.next_retry_at = time.time() + cooldown + state.reason = reason + state.last_error = error_body[:200] + + return state + + def is_available(self, account_id: str, model: str) -> bool: + """Check if an account+model is available (not cooling).""" + states = self._states.get(account_id, {}) + state = states.get(model) + if state is None: + return True + if not state.is_cooling: + return True + return False + + def get_available_accounts(self, model: str, provider: str = "") -> list[dict[str, Any]]: + """Get all non-cooling accounts for a model. + + Returns accounts that are NOT in cooldown for this specific model. + """ + all_accounts = account_service.list_accounts() + available = [] + for acc in all_accounts: + acc_id = acc.get("access_token", "") + if not acc_id: + continue + status = acc.get("status", "") + if status in ("disabled", "error"): + continue + if self.is_available(acc_id, model): + available.append(acc) + return available + + def get_cooldown_info(self, model: str) -> dict[str, Any] | None: + """Get structured cooldown error info for a model. + + Returns None if at least one account is available. + Returns error dict if ALL accounts are cooling. + """ + all_accounts = account_service.list_accounts() + model_accounts = [] + cooling_accounts: list[ModelState] = [] + + for acc in all_accounts: + acc_id = acc.get("access_token", "") + if not acc_id: + continue + status = acc.get("status", "") + if status in ("disabled", "error"): + continue + model_accounts.append(acc) + state = self._states.get(acc_id, {}).get(model) + if state and state.is_cooling: + cooling_accounts.append(state) + + if not model_accounts: + return {"code": "no_accounts", "message": f"No accounts available for {model}"} + + # Check if all are cooling + available = sum(1 for acc in model_accounts + if self.is_available(acc.get("access_token", ""), model)) + + if available > 0: + return None # At least one account is available + + # All cooling — compute collective reset time + now = time.time() + next_reset = min( + (s.next_retry_at for s in cooling_accounts if s.next_retry_at > now), + default=now + 60 + ) + reset_seconds = int(max(0, next_reset - now)) + reasons = list(set(s.reason for s in cooling_accounts)) + + return { + "code": "model_cooldown", + "message": f"All credentials for model {model} are cooling down", + "model": model, + "reset_seconds": reset_seconds, + "reasons": reasons, + "cooling_accounts": len(cooling_accounts), + "total_accounts": len(model_accounts), + } + + def get_state(self, account_id: str, model: str) -> ModelState | None: + """Get the current state for an account+model.""" + return self._states.get(account_id, {}).get(model) + + def get_stats(self) -> dict[str, Any]: + """Get cooldown statistics.""" + total_states = 0 + cooling = 0 + for acc_states in self._states.values(): + total_states += len(acc_states) + cooling += sum(1 for s in acc_states.values() if s.is_cooling) + return { + "total_tracked": total_states, + "cooling": cooling, + "accounts_tracked": len(self._states), + } + + def cleanup_stale(self, max_age: float = 3600): + """Remove cooldown states that are no longer relevant.""" + now = time.time() + for acc_id in list(self._states): + states = self._states[acc_id] + for mdl_key in list(states): + state = states[mdl_key] + if state.status == "active" or (not state.is_cooling and state.next_retry_at > 0): + del states[mdl_key] + if not states: + del self._states[acc_id] + + # ── Internal ──────────────────────────────────────────────── + + def _exponential_backoff(self, level: int) -> float: + """Exponential backoff: 1s, 2s, 4s, ... up to 30min, with jitter.""" + cooldown = self.BACKOFF_BASE * (2 ** level) + cooldown = min(cooldown, self.BACKOFF_MAX) + # 10% jitter + import random + jitter = cooldown * 0.1 * (random.random() * 2 - 1) + return cooldown + jitter + + +# ── Provider Retry-After Parsing ──────────────────────────────── + +def _parse_provider_retry_after( + status_code: int, error_body: str, provider: str = "" +) -> float | None: + """Parse provider-reported retry time from error body. + + Returns retry-after in seconds, or None if not found. + + Handles: + - Codex: {"error":{"type":"usage_limit_reached","resets_in_seconds":123}} + - Codex: {"error":{"type":"usage_limit_reached","resets_at":}} + - Standard: Retry-After header values + - Gemini: "quota exceeded" text + """ + if not error_body: + return None + + body_lower = error_body.lower() + + # Codex usage_limit_reached + if status_code in (429, 400) and "usage_limit_reached" in body_lower: + try: + data = json.loads(error_body) if error_body.strip().startswith("{") else {} + err = data.get("error", {}) + if isinstance(err, dict): + if err.get("type") == "usage_limit_reached": + # resets_at takes priority over resets_in_seconds + resets_at = err.get("resets_at") + if resets_at: + remaining = resets_at - time.time() + if remaining > 0: + return remaining + resets_in = err.get("resets_in_seconds") + if resets_in and resets_in > 0: + return float(resets_in) + except (json.JSONDecodeError, TypeError, KeyError): + pass + + # Codex capacity_exceeded on 400 → treat as retryable + if status_code == 400 and "capacity_exceeded" in body_lower: + return 60 # 1 minute retry + + # Gemini quota exhausted text + if status_code == 429 or (status_code == 403 and "quota" in body_lower): + if provider in ("gemini", "gemini_free"): + # Try to parse "retry in Xs" pattern + match = re.search(r"retry.*?(\d+)\s*s", body_lower) + if match: + return float(match.group(1)) + # Default Gemini cooldown + return 60 + + # Generic retry-after parse from text + match = re.search(r"retry.*?(?:in|after)\s*(\d+)\s*(s|sec|second)s?", body_lower) + if match: + return float(match.group(1)) + + return None + + +def _is_quota_exceeded(error_text: str) -> bool: + """Check if error text indicates quota/rate limit exceeded.""" + if not error_text: + return False + text = error_text.lower() + indicators = [ + "quota exceeded", "rate limit", "too many requests", + "capacity", "exceeded your quota", "429", "rate_limit", + "resource has been exhausted", "usage_limit_reached", + ] + return any(ind in text for ind in indicators) + + +# Singleton +model_cooldown = ModelCooldownManager() diff --git a/services/ninerouter_backup_import.py b/services/ninerouter_backup_import.py new file mode 100644 index 00000000..dd498b74 --- /dev/null +++ b/services/ninerouter_backup_import.py @@ -0,0 +1,286 @@ +""" +9router Backup Import — extract usable tokens from 9router backup files. + +9router exportDb() format: +{ + "settings": {...}, + "providerConnections": [ + { + "provider": "codex", // ChatGPT via OpenAI OAuth + "connectionName": "...", + "data": { "accessToken": "eyJ...", "expiresAt": "...", ... } + }, + { + "provider": "claude", + "data": { "accessToken": "...", ... } + }, + ... + ], + "apiKeys": [...], + "combos": [...] +} + +This module extracts tokens that chatgpt2api can use: +- codex → ChatGPT access tokens (primary target) +- Other OAuth tokens → stored for future provider support +""" + +from __future__ import annotations + +import json +import gzip +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from services.account_service import account_service +from utils.log import logger + + +def detect_9router_backup(data: dict[str, Any]) -> bool: + """Check if a JSON object looks like a 9router backup.""" + if not isinstance(data, dict): + return False + # 9router backup has providerConnections array + if isinstance(data.get("providerConnections"), list): + return True + # Nested data format + inner = data.get("data") or {} + if isinstance(inner, dict) and isinstance(inner.get("providerConnections"), list): + return True + return False + + +def extract_chatgpt_tokens(data: dict[str, Any]) -> list[str]: + """Extract ChatGPT access tokens from a 9router backup. + + Prioritizes codex provider (ChatGPT via OpenAI OAuth). + These tokens can be used directly in chatgpt2api. + + Returns: + List of access token strings (JWT format: eyJ...) + """ + tokens: list[str] = [] + + # Find providerConnections in the data + connections = data.get("providerConnections") or [] + if not connections: + inner = data.get("data") or {} + connections = inner.get("providerConnections") or [] + + if not isinstance(connections, list): + return tokens + + for conn in connections: + if not isinstance(conn, dict): + continue + + provider = str(conn.get("provider") or "").strip().lower() + + # accessToken can be at top level OR nested in "data" field + access_token = str(conn.get("accessToken") or "") + if not access_token: + conn_data = conn.get("data") or {} + if isinstance(conn_data, dict): + access_token = str(conn_data.get("accessToken") or "") + + # ChatGPT-compatible providers + if provider in ("codex", "cursor", "openai"): + if access_token and access_token.startswith("eyJ"): + tokens.append(access_token.strip()) + logger.info({ + "event": "9router_import_token", + "provider": provider, + "connection": str(conn.get("name") or conn.get("id") or "")[:30], + }) + + return tokens + + +def extract_all_oauth_tokens(data: dict[str, Any]) -> list[dict[str, Any]]: + """Extract ALL OAuth tokens from 9router backup (for all providers). + + Returns: + List of {provider, name, accessToken, refreshToken, expiresAt} dicts + """ + all_tokens: list[dict[str, Any]] = [] + + connections = data.get("providerConnections") or [] + if not connections: + inner = data.get("data") or {} + connections = inner.get("providerConnections") or [] + + if not isinstance(connections, list): + return all_tokens + + for conn in connections: + if not isinstance(conn, dict): + continue + + provider = str(conn.get("provider") or "").strip().lower() + + # accessToken can be at top level OR nested in "data" field + access_token = str(conn.get("accessToken") or "") + if not access_token: + conn_data = conn.get("data") or {} + if isinstance(conn_data, dict): + access_token = str(conn_data.get("accessToken") or "") + + if not access_token: + continue + + all_tokens.append({ + "provider": provider, + "name": str(conn.get("name") or conn.get("id") or provider), + "access_token": access_token.strip(), + "refresh_token": str(conn.get("refreshToken") or "").strip() or None, + "expires_at": str(conn.get("expiresAt") or "").strip() or None, + }) + + return all_tokens + + +def extract_combos(data: dict[str, Any]) -> dict[str, list[str]]: + """Extract combo model definitions from 9router backup.""" + combos = data.get("combos") or [] + if not combos and isinstance(data.get("data"), dict): + combos = data["data"].get("combos") or [] + + if not isinstance(combos, list): + return {} + + result: dict[str, list[str]] = {} + for combo in combos: + if not isinstance(combo, dict): + continue + name = str(combo.get("name") or "").strip() + models = combo.get("models") or [] + if name and isinstance(models, list): + result[name] = [str(m) for m in models if m] + + return result + + +def import_9router_backup(filepath: str | Path) -> dict[str, Any]: + """Import a 9router backup file into chatgpt2api. + + Steps: + 1. Read & parse the backup file (supports .json and .json.gz) + 2. Detect if it's a 9router backup + 3. Extract ChatGPT-compatible tokens (codex provider) + 4. Add them to the account pool + 5. Optionally merge combo models + + Returns: + { imported_tokens: int, skipped: int, combos_merged: int, errors: [...] } + """ + path = Path(filepath) + if not path.exists(): + return {"imported_tokens": 0, "skipped": 0, "combos_merged": 0, "errors": [f"File not found: {filepath}"]} + + errors: list[str] = [] + + # Read file (supports gzip) + try: + if path.suffix == ".gz": + with gzip.open(path, "rb") as f: + data = json.loads(f.read().decode("utf-8")) + else: + data = json.loads(path.read_text(encoding="utf-8")) + except Exception as exc: + return {"imported_tokens": 0, "skipped": 0, "combos_merged": 0, "errors": [f"Failed to read backup: {exc}"]} + + if not isinstance(data, dict): + return {"imported_tokens": 0, "skipped": 0, "combos_merged": 0, "errors": ["Invalid backup format: not a JSON object"]} + + # Check if it's a 9router backup + if not detect_9router_backup(data): + # Might be chatgpt2api backup — try state_backup + try: + from services.state_backup import state_backup + report = state_backup.import_all(data) + return { + "imported_tokens": report.items_restored.get("accounts", 0), + "skipped": 0, + "combos_merged": report.items_restored.get("combo_models", 0), + "errors": report.errors, + "import_type": "chatgpt2api", + } + except Exception as exc: + return {"imported_tokens": 0, "skipped": 0, "combos_merged": 0, "errors": [f"Not a recognized backup format: {exc}"]} + + # Extract ChatGPT tokens from 9router backup + tokens = extract_chatgpt_tokens(data) + + imported = 0 + skipped = 0 + + if tokens: + try: + # Add to pool as codex type — get_token_for_request filters by type=codex + result = account_service.add_accounts_with_type(tokens, "codex") + for token in tokens: + account_service.update_account(token, { + "image_quota_unknown": False, + "quota": 10, + "status": "active", + }) + imported = result.get("added", 0) + result.get("updated", 0) + skipped = result.get("skipped", 0) + logger.info({ + "event": "9router_backup_imported", + "tokens_found": len(tokens), + "imported": imported, + "skipped": skipped, + }) + except Exception as exc: + errors.append(f"Failed to add accounts: {exc}") + + # Also extract all OAuth tokens for reference + all_oauth = extract_all_oauth_tokens(data) + provider_counts: dict[str, int] = {} + for t in all_oauth: + p = t["provider"] + provider_counts[p] = provider_counts.get(p, 0) + 1 + + # Extract combos + combos = extract_combos(data) + combos_merged = 0 + if combos: + try: + from services.config import config + existing_combos = config.data.get("combo_models") or {} + if isinstance(existing_combos, dict): + merged = {**combos, **existing_combos} # Existing takes priority + config.data["combo_models"] = merged + config._save() + combos_merged = len(combos) + except Exception as exc: + errors.append(f"Failed to merge combos: {exc}") + + return { + "imported_tokens": imported, + "skipped": skipped, + "combos_merged": combos_merged, + "total_tokens_found": len(tokens), + "oauth_providers_found": provider_counts, + "errors": errors, + "import_type": "9router", + } + + +def import_9router_backup_from_api(filepath: str) -> dict[str, Any]: + """API-friendly wrapper with user-facing messages.""" + result = import_9router_backup(filepath) + + if result["errors"]: + return {"ok": False, **result} + + return { + "ok": True, + "message": ( + f"Đã import {result['imported_tokens']} token ChatGPT từ backup 9router. " + f"({result['skipped']} đã tồn tại, {result['combos_merged']} combo đã hợp nhất)" + ), + **result, + } diff --git a/services/oauth_service.py b/services/oauth_service.py new file mode 100644 index 00000000..e9f63448 --- /dev/null +++ b/services/oauth_service.py @@ -0,0 +1,181 @@ +""" +OAuth Service — PKCE login flow for Codex (chat) and Google (image). +Port from 9router oauth/ directory. + +Codex OAuth: +- Client ID: app_EMoamEEZ73f0CkXaXp7hrann +- Auth: https://auth.openai.com/oauth/authorize +- Token: https://auth.openai.com/oauth/token + +Google OAuth (chatgpt.com): +- Direct session token from chatgpt.com/api/auth/session +- User logs in via browser, copy token +""" + +from __future__ import annotations + +import base64 +import hashlib +import json +import os +import secrets +import urllib.parse +from typing import Any + +from curl_cffi import requests + +from services.account_service import account_service +from utils.log import logger + +# Codex OAuth config (from 9router) +CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann" +CODEX_AUTH_URL = "https://auth.openai.com/oauth/authorize" +CODEX_TOKEN_URL = "https://auth.openai.com/oauth/token" +CODEX_REDIRECT_URI = "http://localhost:3030/api/oauth/codex/callback" +CODEX_SCOPE = "openid profile email offline_access model.request model.read" + + +def generate_pkce() -> dict[str, str]: + """Generate PKCE code verifier and challenge.""" + code_verifier = base64.urlsafe_b64encode(secrets.token_bytes(32)).rstrip(b"=").decode() + digest = hashlib.sha256(code_verifier.encode()).digest() + code_challenge = base64.urlsafe_b64encode(digest).rstrip(b"=").decode() + state = secrets.token_hex(16) + return { + "code_verifier": code_verifier, + "code_challenge": code_challenge, + "state": state, + } + + +def get_codex_auth_url(base_url: str = "http://localhost:3030") -> dict[str, str]: + """Generate Codex OAuth authorization URL with PKCE. + + IMPORTANT: redirect_uri MUST be localhost for Codex CLI OAuth. + OpenAI only trusts localhost redirects — remote IPs require phone verification. + If chatgpt2api is not on your local machine, use the manual exchange flow: + 1. Open auth_url in browser + 2. Authorize + 3. Copy the full redirect URL (localhost:3030/auth/callback?code=...) + 4. POST that URL to /api/oauth/codex/exchange + """ + pkce = generate_pkce() + redirect_uri = "http://localhost:1455/auth/callback" + + params = { + "response_type": "code", + "client_id": CODEX_CLIENT_ID, + "redirect_uri": redirect_uri, + "scope": "openid profile email offline_access", + "code_challenge": pkce["code_challenge"], + "code_challenge_method": "S256", + "id_token_add_organizations": "true", + "codex_cli_simplified_flow": "true", + "originator": "codex_cli_rs", + "state": pkce["state"], + } + + # Build query string manually to use %20 instead of + (like 9router does) + query_parts = [] + for k, v in params.items(): + encoded_key = urllib.parse.quote(str(k), safe="") + encoded_val = urllib.parse.quote(str(v), safe="") + query_parts.append(f"{encoded_key}={encoded_val}") + query_string = "&".join(query_parts) + + auth_url = f"{CODEX_AUTH_URL}?{query_string}" + + # Store PKCE data temporarily (in-memory, short-lived) + _pending_auths[pkce["state"]] = { + "code_verifier": pkce["code_verifier"], + "redirect_uri": redirect_uri, + } + + return { + "auth_url": auth_url, + "state": pkce["state"], + } + + +# In-memory store for pending OAuth flows (state → {code_verifier, redirect_uri}) +_pending_auths: dict[str, dict[str, str]] = {} + + +def exchange_codex_code(code: str, state: str) -> dict[str, Any]: + """Exchange authorization code for Codex OAuth token.""" + pending = _pending_auths.pop(state, None) + if not pending: + raise ValueError("Invalid or expired OAuth state. Please try again.") + + body = { + "client_id": CODEX_CLIENT_ID, + "code": code, + "redirect_uri": pending["redirect_uri"], + "code_verifier": pending["code_verifier"], + "grant_type": "authorization_code", + } + + resp = requests.post( + CODEX_TOKEN_URL, + headers={"Content-Type": "application/x-www-form-urlencoded"}, + data=body, + timeout=30, + impersonate="chrome110", + ) + + if resp.status_code != 200: + raise RuntimeError(f"Token exchange failed: {resp.status_code} {resp.text[:200]}") + + token_data = resp.json() + access_token = token_data.get("access_token", "") + refresh_token = token_data.get("refresh_token", "") + + if access_token: + # Add to account pool with default quota for both chat and images + account_service.add_accounts([access_token]) + account_service.update_account(access_token, { + "image_quota_unknown": True, + "quota": 10, + "status": "active", + }) + + return { + "ok": True, + "message": "Đăng nhập Codex OAuth thành công! Token đã được thêm.", + "access_token_prefix": access_token[:20] + "..." if access_token else "", + "has_refresh_token": bool(refresh_token), + } + + +def get_chatgpt_session_url() -> str: + """Return URL for getting chatgpt.com session token.""" + return "https://chatgpt.com/api/auth/session" + + +# ===== Token từ backup 9router ===== + +def detect_token_type(access_token: str) -> str: + """Detect token type from JWT claims. + + Returns: "codex" | "google" | "unknown" + """ + try: + parts = access_token.split(".") + if len(parts) < 2: + return "unknown" + payload = parts[1] + payload += "=" * (4 - len(payload) % 4) + decoded = json.loads(base64.urlsafe_b64decode(payload)) + + client_id = decoded.get("client_id", "") + sub = decoded.get("sub", "") + + if client_id == CODEX_CLIENT_ID: + return "codex" + if sub.startswith("google-oauth2"): + return "google" + if "chatgpt_account_id" in str(decoded.get("https://api.openai.com/auth", {})): + return "codex" + return "unknown" + except Exception: + return "unknown" diff --git a/services/openai_backend_api.py b/services/openai_backend_api.py index 46a61f52..ad0f82bc 100644 --- a/services/openai_backend_api.py +++ b/services/openai_backend_api.py @@ -212,7 +212,7 @@ def get_user_info(self) -> Dict[str, Any]: "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 "正常"), + "status": "active" if image_quota_unknown and plan_type.lower() != "free" else ("limited" if quota == 0 else "active"), } logger.debug({ "event": "backend_user_info_result", @@ -359,9 +359,9 @@ def _api_messages_to_conversation_messages(self, messages: list[Dict[str, Any]]) }) return conversation_messages - def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str) -> Dict[str, Any]: + def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, timezone: str, tools: Optional[list[Dict[str, Any]]] = None, tool_choice: Any = None) -> Dict[str, Any]: """把标准 messages 构造成 web 对话请求体。""" - return { + payload: Dict[str, Any] = { "action": "next", "messages": self._api_messages_to_conversation_messages(messages), "model": model, @@ -391,6 +391,11 @@ def _conversation_payload(self, messages: list[Dict[str, Any]], model: str, time "screen_width": 2560, }, } + if tools: + payload["tools"] = tools + if tool_choice is not None: + payload["tool_choice"] = tool_choice + return payload def _image_model_slug(self, model: str) -> str: """把标准图片模型名映射到底层 model slug。""" @@ -792,6 +797,8 @@ def stream_conversation( prompt: str = "", images: Optional[list[str]] = None, system_hints: Optional[list[str]] = None, + tools: Optional[list[Dict[str, Any]]] = None, + tool_choice: Any = None, ) -> Iterator[str]: system_hints = system_hints or [] if "picture_v2" in system_hints: @@ -802,7 +809,7 @@ def stream_conversation( self._bootstrap() requirements = self._get_chat_requirements() path, timezone = self._chat_target() - payload = self._conversation_payload(normalized, model, timezone) + payload = self._conversation_payload(normalized, model, timezone, tools=tools, tool_choice=tool_choice) response = self.session.post( self.base_url + path, headers=self._conversation_headers(path, requirements), diff --git a/services/protocol/conversation.py b/services/protocol/conversation.py index 1cddea84..26ca7658 100644 --- a/services/protocol/conversation.py +++ b/services/protocol/conversation.py @@ -5,6 +5,7 @@ import json import re import time +import uuid from dataclasses import dataclass, field from pathlib import Path from typing import Any, Iterable, Iterator @@ -17,6 +18,87 @@ from utils.helper import IMAGE_MODELS, extract_image_from_message_content from utils.log import logger +TOOL_CALL_RE = re.compile(r'(.*?)', re.DOTALL) +TOOL_CALL_DIRECT_RE = re.compile(r'<([A-Z][A-Za-z0-9_]*?)>(.*?)', re.DOTALL) +TOOL_CALL_SELF_CLOSING_RE = re.compile(r'', re.DOTALL) +TOOL_CALL_DIRECT_SELF_CLOSING_RE = re.compile(r'<([A-Z][A-Za-z0-9_]*?)\s*/>', re.DOTALL) +JSON_TOOL_CALL_RE = re.compile(r'\{\s*"path"\s*:\s*"([^"]+)"\s*,\s*"args"\s*:\s*(\{.*?\})\s*\}', re.DOTALL) +CONTROL_TOKEN_RE = re.compile(r'<\|im_(?:start|end)\|>') +# Strip ChatGPT internal citation markers, e.g. citeturn0search7, 【4†citeturn0search8】 +CITATION_RE = re.compile(r'【?[0-9†]*\s*citeturn[^\s】]*\s*】?', re.IGNORECASE) + + +# Exact XML_WRAP_HINT from Gemini-FastAPI +_XML_WRAP_HINT = ( + "\nYou MUST wrap every tool call response inside a single fenced block exactly like:\n" + '```xml\n{"arg": "value"}\n```\n' + "Do not surround the fence with any other text or whitespace; otherwise the call will be ignored.\n" +) + + +def _build_tool_prompt(tools: list[dict[str, Any]], tool_choice: Any = None) -> str: + """Generate a system prompt chunk describing available tools. Mirrors Gemini-FastAPI _build_tool_prompt.""" + if not tools: + return "" + lines: list[str] = [ + "You can invoke the following developer tools. Call a tool only when it is required and follow the JSON schema exactly when providing arguments." + ] + for tool in tools: + f = tool.get("function", {}) + name = f.get("name") + desc = f.get("description") or "No description provided." + lines.append(f"Tool `{name}`: {desc}") + params = f.get("parameters") or {} + properties = params.get("properties") or {} + if properties: + schema_text = json.dumps(params, ensure_ascii=False, indent=2) + lines.append("Arguments JSON schema:") + lines.append(schema_text) + else: + lines.append("Arguments JSON schema: {}") + lines.append(f" >> `{name}` requires NO arguments. You MUST call it with exactly: {{}}") + + if tool_choice == "none": + lines.append( + "For this request you must not call any tool. Provide the best possible natural language answer." + ) + elif tool_choice == "required": + lines.append( + "You must call at least one tool before responding to the user. Do not provide a final user-facing answer until a tool call has been issued." + ) + elif isinstance(tool_choice, dict) and tool_choice.get("type") == "function": + target = (tool_choice.get("function") or {}).get("name", "") + if target: + lines.append( + f"You are required to call the tool named `{target}`. Do not call any other tool." + ) + + lines.append( + "When you decide to call a tool you MUST respond with nothing except a single fenced block exactly like the template below." + ) + lines.append( + "The fenced block MUST use ```xml as the opening fence and ``` as the closing fence. Do not add text before or after it." + ) + lines.append("```xml") + lines.append('{"argument": "value"}') + lines.append("```") + lines.append( + "Use double quotes for JSON keys and values. If you omit the fenced block or include any extra text, the system will assume you are NOT calling a tool and your request will fail." + ) + lines.append( + "If multiple tool calls are required, include multiple entries inside the same fenced block. Without a tool call, reply normally and do NOT emit any ```xml fence." + ) + return "\n".join(lines) + + +def _strip_system_hints(text: str) -> str: + """Remove system-level hint text and ChatGPT internal markers from responses.""" + if not text: + return text + text = CONTROL_TOKEN_RE.sub("", text) + text = CITATION_RE.sub("", text) + return text.strip() + class ImageGenerationError(Exception): def __init__( @@ -91,13 +173,90 @@ def message_text(content: Any) -> str: return "" -def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]: +# Maximum payload size in bytes before triggering truncation. +# ChatGPT backend limit is ~100 KB; we use 80 KB to be safe. +_MAX_PAYLOAD_BYTES = 80_000 + + +def _truncate_messages(messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Drop oldest non-system messages when the serialized payload exceeds the size limit. + + This prevents HTTP 413 (Request Entity Too Large) errors from the ChatGPT backend. + Home Assistant accumulates the entire conversation history (including tool call results) + across multiple iterations, which can easily exceed the limit. + + Strategy: + 1. Keep all system messages (they define behavior and tools). + 2. Drop oldest non-system messages until under the threshold. + 3. If still over limit, truncate the system message (usually HA entity list). + 4. If still over limit, truncate the last user message content as a last resort. + """ + payload = json.dumps(messages, ensure_ascii=False, default=str) + if len(payload.encode("utf-8")) <= _MAX_PAYLOAD_BYTES: + return messages + + # Separate system messages from the rest + system_msgs = [m for m in messages if m.get("role") == "system"] + other_msgs = [m for m in messages if m.get("role") != "system"] + + # Drop oldest non-system messages until under limit + while other_msgs: + test_payload = json.dumps(system_msgs + other_msgs, ensure_ascii=False, default=str) + if len(test_payload.encode("utf-8")) <= _MAX_PAYLOAD_BYTES: + break + other_msgs.pop(0) + + test_payload = json.dumps(system_msgs + other_msgs, ensure_ascii=False, default=str) + + # If still over limit, truncate the last system message (typically the HA entity list) + if len(test_payload.encode("utf-8")) > _MAX_PAYLOAD_BYTES: + if system_msgs: + last_sys = system_msgs[-1] + if isinstance(last_sys.get("content"), str): + content = last_sys["content"] + excess = len(test_payload.encode("utf-8")) - _MAX_PAYLOAD_BYTES + allowed_len = max(500, len(content) - excess - 200) + if len(content) > allowed_len: + last_sys["content"] = content[:allowed_len] + "\n\n[System prompt truncated due to size limits]" + test_payload = json.dumps(system_msgs + other_msgs, ensure_ascii=False, default=str) + + # If still over limit, truncate last user content + if other_msgs and len(test_payload.encode("utf-8")) > _MAX_PAYLOAD_BYTES: + last_user = other_msgs[-1] + if last_user.get("role") == "user" and isinstance(last_user.get("content"), str): + content = last_user["content"] + excess = len(test_payload.encode("utf-8")) - _MAX_PAYLOAD_BYTES + allowed_len = max(500, len(content) - excess - 200) + if len(content) > allowed_len: + last_user["content"] = content[:allowed_len] + "\n\n[Content truncated due to size limits]" + + result = system_msgs + other_msgs + return result + + +def normalize_messages(messages: object, system: Any = None, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None) -> list[dict[str, Any]]: normalized = [] - if config.global_system_prompt: - normalized.append({"role": "system", "content": config.global_system_prompt}) + + # Inject global system prompt and tools documentation + system_instructions = config.global_system_prompt or "" + + # Inject Karpathy guidelines if mode enabled + if config.karpathy_mode: + from services.karpathy_guidelines import load_guidelines + karpathy_prompt = load_guidelines() + if karpathy_prompt: + system_instructions = karpathy_prompt + "\n\n" + system_instructions + + if tools: + system_instructions += _build_tool_prompt(tools, tool_choice=tool_choice) + + if system_instructions: + normalized.append({"role": "system", "content": system_instructions}) + 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): @@ -105,6 +264,37 @@ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, A role = message.get("role", "user") content = message.get("content", "") text = message_text(content) + + # Map 'developer' role to 'system' (Gemini-FastAPI compat) + if role == "developer": + role = "system" + + # Map 'tool' role to 'user' for Web ChatGPT visibility + # Preserve tool_call_id in the text so the model understands context + if role == "tool": + role = "user" + tool_call_id = message.get("tool_call_id") or "" + tool_name = message.get("name") or "" + header = "[Tool Result]" + if tool_name: + header += f" {tool_name}" + if tool_call_id: + header += f" (id: {tool_call_id})" + # Detect tool failure to prevent infinite retry loops + failure_suffix = "" + try: + result_data = json.loads(text) if text else {} + if isinstance(result_data, dict) and result_data.get("success") is False: + err = result_data.get("error") or "unknown error" + failure_suffix = ( + f"\n\n[STOP: Tool call FAILED: \"{err}\". " + "Do NOT retry this tool. Do NOT call any tool again. " + "Respond to the user in plain language explaining the issue.]" + ) + except (json.JSONDecodeError, TypeError): + pass + text = f"{header}: {text}{failure_suffix}" + images: list[tuple[bytes, str]] = [] if role == "user": images.extend(extract_image_from_message_content(content)) @@ -123,7 +313,28 @@ def normalize_messages(messages: object, system: Any = None) -> list[dict[str, A parts.append({"type": "image", "data": data, "mime": mime}) normalized.append({"role": role, "content": parts}) else: - normalized.append({"role": role, "content": text}) + msg = {"role": role, "content": text} + if "tool_calls" in message: + msg["tool_calls"] = message["tool_calls"] + if "tool_call_id" in message: + msg["tool_call_id"] = message["tool_call_id"] + if "name" in message: + msg["name"] = message["name"] + normalized.append(msg) + + # Inject XML tool-call hint into last user message (mirrors Gemini-FastAPI _append_xml_hint_to_last_user_message) + if tools: + hint_stripped = _XML_WRAP_HINT.strip() + for i in range(len(normalized) - 1, -1, -1): + if normalized[i].get("role") == "user": + existing = normalized[i].get("content") or "" + if isinstance(existing, str) and hint_stripped not in existing: + normalized[i] = dict(normalized[i]) + normalized[i]["content"] = existing + _XML_WRAP_HINT + break + + # Truncate oversized payload to prevent HTTP 413 errors + normalized = _truncate_messages(normalized) return normalized @@ -224,6 +435,8 @@ class ConversationRequest: response_format: str = "b64_json" base_url: str | None = None message_as_error: bool = False + tools: list[dict[str, Any]] | None = None + tool_choice: Any = None @dataclass @@ -458,9 +671,24 @@ def conversation_events( prompt: str = "", images: list[str] | None = None, size: str | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, ) -> Iterator[dict[str, Any]]: - normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else [])) + normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []), tools=tools, tool_choice=tool_choice) image_model = str(model or "").strip() in IMAGE_MODELS + + if not image_model: + last_user_text = "" + for msg in reversed(normalized): + if str(msg.get("role") or "") == "user": + last_user_text = str(msg.get("content") or "").strip().lower() + break + if any(keyword in last_user_text for keyword in ("trạng thái nhà", "tình trạng nhà", "nhà đang", "state house", "home status")): + normalized.insert(0, { + "role": "system", + "content": "For smart-home status questions, call available live context/tool first (such as GetLiveContext) before answering. Do not answer from assumptions.", + }) + 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)) if image_model else prompt @@ -470,6 +698,8 @@ def conversation_events( prompt=final_prompt, images=images if image_model else None, system_hints=["picture_v2"] if image_model else None, + tools=tools, + tool_choice=tool_choice, ) yield from iter_conversation_payloads(payloads, history_text, history_messages) @@ -478,7 +708,7 @@ def text_backend() -> OpenAIBackendAPI: return OpenAIBackendAPI(access_token=account_service.get_text_access_token()) -def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]: +def stream_conversation_events(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[dict[str, Any]]: attempted_tokens: set[str] = set() token = getattr(backend, "access_token", "") emitted = False @@ -489,13 +719,17 @@ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) 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: + for event in conversation_events( + active_backend, + messages=request.messages, + model=request.model, + prompt=request.prompt, + tools=request.tools, + tool_choice=request.tool_choice, + ): + if event: emitted = True - yield delta + yield event account_service.mark_text_used(token) return except Exception as exc: @@ -508,6 +742,15 @@ def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) raise +def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]: + for event in stream_conversation_events(backend, request): + if event.get("type") != "conversation.delta": + continue + delta = str(event.get("delta") or "") + if delta: + yield delta + + def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str: return "".join(stream_text_deltas(backend, request)) diff --git a/services/protocol/openai_v1_chat_complete.py b/services/protocol/openai_v1_chat_complete.py index 29dfe18d..06b19465 100644 --- a/services/protocol/openai_v1_chat_complete.py +++ b/services/protocol/openai_v1_chat_complete.py @@ -1,5 +1,6 @@ from __future__ import annotations +import json import time import uuid from typing import Any, Iterable, Iterator @@ -19,7 +20,35 @@ stream_text_deltas, text_backend, ) +from services.account_service import account_service +from services.backend_router import backend_router +from services.config import config +from services.model_cooldown import model_cooldown +from services.search_service import search_service from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count +from utils.log import logger + + +def _extract_status(error_text: str) -> int: + """Extract HTTP status code from error message text.""" + import re + text = str(error_text) + match = re.search(r'\b(4\d\d|5\d\d|error\s+(\d+))', text, re.IGNORECASE) + if match: + code = match.group(2) or match.group(1) + try: + return int(code) + except ValueError: + pass + # Check for keyword patterns + lower = text.lower() + if "401" in lower or "unauthorized" in lower: return 401 + if "402" in lower: return 402 + if "403" in lower or "forbidden" in lower: return 403 + if "404" in lower: return 404 + if "429" in lower or "rate" in lower or "quota" in lower: return 429 + if "503" in lower or "502" in lower or "500" in lower: return 500 + return 0 def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]: @@ -58,11 +87,11 @@ def completion_response( } -def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]: +def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str, tools: list[dict[str, Any]] | None = None, tool_choice: Any = None) -> Iterator[dict[str, Any]]: completion_id = f"chatcmpl-{uuid.uuid4().hex}" created = int(time.time()) sent_role = False - request = ConversationRequest(model=model, messages=messages) + request = ConversationRequest(model=model, messages=messages, tools=tools, tool_choice=tool_choice) for delta_text in stream_text_deltas(backend, request): if not sent_role: sent_role = True @@ -108,10 +137,16 @@ def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[byt return model, prompt, parse_image_count(body.get("n")), images -def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]: +def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]], list[dict[str, Any]] | None, Any]: model = str(body.get("model") or "auto").strip() or "auto" - messages = normalize_messages(chat_messages_from_body(body)) - return model, messages + messages = chat_messages_from_body(body) + tools = body.get("tools") + if isinstance(tools, list) and tools: + tools = [t for t in tools if isinstance(t, dict)] + else: + tools = None + tool_choice = body.get("tool_choice") + return model, messages, tools, tool_choice def image_result_content(result: dict[str, Any]) -> str: @@ -172,13 +207,542 @@ def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: st 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) - return stream_text_chat_completion(text_backend(), messages, model) + # Image chat requests always use existing DALL-E flow if is_image_chat_request(body): + if body.get("stream"): + return image_chat_events(body) return image_chat_response(body) - model, messages = text_chat_parts(body) - request = ConversationRequest(model=model, messages=messages) + + model, messages, tools, tool_choice = text_chat_parts(body) + + # Check if this is a combo model — try each model until success + if backend_router.is_combo(model): + routes = backend_router.route_combo(model) + last_error = "" + for route in routes: + try: + # Check cooldown before trying this provider + cooldown = model_cooldown.get_cooldown_info(route.model) + if cooldown: + logger.warning({"event": "model_cooldown_skip", "model": route.model, **cooldown}) + last_error = cooldown["message"] + continue + + logger.info({"event": "combo_try", "combo": model, "provider": route.provider, "model": route.model}) + if search_service.is_enabled and route.provider != "chatgpt": + messages_copy = search_service.process_messages(messages) + else: + messages_copy = messages + result = _dispatch(route, messages_copy, tools, tool_choice, body) + # Record success on cooldown manager + model_cooldown.record_success("combo:" + model, route.model) + return result + except Exception as exc: + last_error = str(exc) + logger.warning({"event": "combo_fail", "combo": model, "provider": route.provider, "error": last_error[:200]}) + # Record failure for per-model cooldown + model_cooldown.record_failure( + account_id="combo:" + model, + model=route.model, + status_code=_extract_status(last_error), + error_body=last_error, + provider=route.provider, + ) + continue + return completion_response(model=model, content=f"All providers failed. Last error: {last_error[:200]}", messages=messages) + + # Single model — route directly + route = backend_router.route(model, messages) + + # Apply search injection for non-ChatGPT backends + if search_service.is_enabled and route.provider != "chatgpt": + messages = search_service.process_messages(messages) + + return _dispatch(route, messages, tools, tool_choice, body) + + +def _dispatch(route, messages, tools, tool_choice, body): + """Dispatch to the correct provider handler.""" + if route.provider == "opencode": + return _handle_opencode_chat(route.model, messages, body.get("stream"), body) + elif route.provider == "ninerouter": + return _handle_ninerouter_chat(route.model, messages, tools, tool_choice, body.get("stream"), body) + elif route.provider in ("openai_oauth", "codex"): + return _handle_openai_oauth_chat(route.model, messages, tools, tool_choice, body.get("stream"), body) + elif route.provider == "gemini_free": + return _handle_gemini_chat(route.model, messages, body.get("stream"), body) + elif route.provider == "nvidia_nim": + return _handle_nvidia_chat(route.model, messages, tools, tool_choice, body.get("stream"), body) + elif route.provider.startswith("custom:"): + return _handle_custom_openai_chat(route.provider, route.model, messages, tools, tool_choice, body.get("stream"), body) + elif route.provider == "chatgpt": + return _handle_chatgpt_chat(route.model, messages, tools, tool_choice, body.get("stream"), body) + else: + logger.warning({"event": "unknown_provider", "provider": route.provider, "fallback": "chatgpt"}) + return _handle_chatgpt_chat(route.model, messages, tools, tool_choice, body.get("stream"), body) + + +def _handle_chatgpt_chat( + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Existing ChatGPT flow — unchanged.""" + if stream: + return stream_text_chat_completion(text_backend(), messages, model, tools, tool_choice) + request = ConversationRequest(model=model, messages=messages, tools=tools, tool_choice=tool_choice) return completion_response(model, collect_text(text_backend(), request), messages=messages) + + +def _handle_opencode_chat( + model: str, + messages: list[dict[str, Any]], + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """OpenCode chat — no 24KB payload limit, no auth required.""" + from services.providers.opencode import opencode_provider + + # Strip oc/ prefix if present + opencode_model = model + if model.startswith("oc/"): + opencode_model = model[3:] + elif model == "auto": + opencode_model = "auto" + + logger.info({ + "event": "opencode_chat_routed", + "model": opencode_model, + "stream": stream, + "message_count": len(messages), + }) + + temperature = float(body.get("temperature") or 0.7) + max_tokens = body.get("max_tokens") + + if stream: + return _stream_opencode_response(opencode_model, messages, temperature, max_tokens, body) + else: + return _opencode_completion_response(opencode_model, messages, temperature, max_tokens) + + +def _stream_opencode_response( + model: str, + messages: list[dict[str, Any]], + temperature: float, + max_tokens: int | None, + body: dict[str, Any], +) -> Iterator[dict[str, Any]]: + """Stream response from OpenCode — extract tool calls from text if present.""" + from services.providers.opencode import opencode_provider + + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + accumulated = "" + + try: + sse_stream = opencode_provider.chat_completions( + messages=messages, model=model, stream=True, + temperature=temperature, max_tokens=max_tokens, + ) + + for line in sse_stream: + if line.startswith("data: "): + payload = line[6:].strip() + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + delta_text = "" + choices = chunk.get("choices", []) + if choices and isinstance(choices[0], dict): + delta_text = str(choices[0].get("delta", {}).get("content", "") or "") + accumulated += delta_text + chunk["id"] = completion_id + chunk["created"] = created + chunk["model"] = model + if delta_text and not sent_role: + chunk["choices"][0]["delta"] = {"role": "assistant", "content": delta_text} + sent_role = True + yield chunk + except Exception: + continue + + # On completion, check if response contains tool calls + tool_calls = _extract_tool_calls_from_text(accumulated) + if tool_calls: + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"tool_calls": tool_calls}, "finish_reason": None}], + } + + if not sent_role: + yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created) + yield completion_chunk(model, {}, "stop", completion_id, created) + + except Exception as exc: + logger.error({"event": "opencode_stream_fatal", "error": str(exc)}) + yield completion_chunk(model, {"role": "assistant", "content": f"OpenCode error: {exc}"}, "stop", completion_id, created) + + +def _opencode_completion_response( + model: str, + messages: list[dict[str, Any]], + temperature: float, + max_tokens: int | None, +) -> dict[str, Any]: + """Non-streaming response from OpenCode — parse text JSON into native tool_calls.""" + from services.providers.opencode import opencode_provider + + try: + result = opencode_provider.chat_completions( + messages=messages, + model=model, + stream=False, + temperature=temperature, + max_tokens=max_tokens, + ) + + content = "" + choices = result.get("choices", []) + if choices and isinstance(choices[0], dict): + content = str(choices[0].get("message", {}).get("content", "") or "") + + # Parse text JSON tool calls into native format + tool_calls = _extract_tool_calls_from_text(content) + message = {"role": "assistant", "content": ""} + if tool_calls: + message["tool_calls"] = tool_calls + else: + message["content"] = content + + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": count_message_tokens(messages, model), + "completion_tokens": count_text_tokens(content, model), + "total_tokens": count_message_tokens(messages, model) + count_text_tokens(content, model), + }, + } + + except Exception as exc: + logger.error({"event": "opencode_completion_error", "error": str(exc)}) + return completion_response( + model=model, + content=f"OpenCode error: {exc}", + messages=messages, + ) + + +# ── Helper for entity_id → domain conversion ── + +def _convert_params(params): + """Convert OpenCode params to HA-compatible format (entity_ids → domain).""" + if isinstance(params, dict) and "entity_ids" in params: + eids = params["entity_ids"] + if isinstance(eids, list) and eids: + domains = list(set(eid.split(".")[0] for eid in eids if isinstance(eid, str))) + return {"domain": domains} + if isinstance(params, list): + if all(isinstance(x, str) for x in params): + if any("." in str(x) for x in params): + domains = list(set(str(x).split(".")[0] for x in params)) + return {"domain": domains} + return {"entities": params} + return {"entities": params} + if not isinstance(params, dict): + return {} + return params + + +def _extract_tool_calls_from_text(text: str) -> list[dict[str, Any]] | None: + """Parse text tool calls from OpenCode response. + + Only extract if the response is PURELY a tool call (no conversational answer). + If there's text after the tool call JSON, assume it's already a complete answer. + """ + if not text: + return None + import re as _re + + # Check if this is a pure tool call — first non-whitespace is a tool name or JSON + stripped = text.strip() + + # If text contains both a tool call AND a conversational answer (after the JSON), + # the answer is the main intent — don't extract tool call + # Pattern: "ToolName\n{json}\n\nAnswer text..." → already answered, skip + + # Format 1: JSON with "action" key + match = _re.search(r'\{[^{}]*"action"\s*:\s*"([^"]+)"\s*[,}][^{}]*\}', stripped) + if match: + # Only use if this is MOSTLY a tool call (not followed by long text) + after_json = stripped[match.end():].strip() + if len(after_json) < 50: # Short or no follow-up text → pure tool call + try: + data = json.loads(match.group(0)) + action = data.get("action", "") + params = _convert_params(data.get("params") or data.get("entity_ids") or data.get("domain") or {}) + if action: + return [{"id": f"call_{uuid.uuid4().hex[:12]}", "type": "function", + "function": {"name": action, "arguments": json.dumps(params, ensure_ascii=False)}}] + except (json.JSONDecodeError, AttributeError): + pass + + # Format 2: ToolName\n{JSON} + match = _re.search(r'^([A-Z][A-Za-z0-9_]+)\s*\n\s*(\[[^\]]*\]|\{[^{}]*\})', stripped) + if match: + after_json = stripped[match.end():].strip() + if len(after_json) < 50: + try: + tool_name = match.group(1) + params = _convert_params(json.loads(match.group(2))) + if not isinstance(params, dict): params = {} + return [{"id": f"call_{uuid.uuid4().hex[:12]}", "type": "function", + "function": {"name": tool_name, "arguments": json.dumps(params, ensure_ascii=False)}}] + except (json.JSONDecodeError, AttributeError): + pass + + # Format 3: {"tool": "X"} or {"name": "X"} + match = _re.search(r'\{\s*"(?:tool|name)"\s*:\s*"([^"]+)"\s*,\s*"parameters"\s*:\s*(\{.*?\}|\[.*?\])\s*\}', stripped, _re.DOTALL) + if match: + after_json = stripped[match.end():].strip() + if len(after_json) < 50: + try: + tool_name = match.group(1) + params = _convert_params(json.loads(match.group(2))) + if not isinstance(params, dict): params = {} + return [{"id": f"call_{uuid.uuid4().hex[:12]}", "type": "function", + "function": {"name": tool_name, "arguments": json.dumps(params, ensure_ascii=False)}}] + except (json.JSONDecodeError, AttributeError): + pass + + return None + + +def _handle_openai_oauth_chat( + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Use Codex OAuth token to call chatgpt.com/backend-api/codex/responses — same as 9router.""" + from services.providers.openai_oauth import codex_oauth + + pure_model = model[3:] if model.startswith("cx/") else model + if not pure_model or pure_model == "auto": + pure_model = "auto" + + logger.info({ + "event": "openai_oauth_chat", + "model": pure_model, + "stream": stream, + }) + + temperature = body.get("temperature") + max_tokens = body.get("max_tokens") + + attempted: set[str] = set() + last_error = "" + + while True: + try: + token = codex_oauth.get_token_for_request(attempted) + except RuntimeError as exc: + return completion_response(model=model, content=str(exc), messages=messages) + + if token in attempted: + break + attempted.add(token) + + try: + if stream: + return codex_oauth.chat_completions( + access_token=token, messages=messages, model=pure_model, + stream=True, temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + ) + else: + result = codex_oauth.chat_completions( + access_token=token, messages=messages, model=pure_model, + stream=False, temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + ) + account_service.mark_text_used(token) + return result + except Exception as exc: + last_error = str(exc) + # On 401 → remove bad token and try next + if any(x in last_error.lower() for x in ("expired", "401")): + account_service.remove_invalid_token(token, "codex_oauth") + continue + # On 400/429 → try next token (don't remove, might be temporary) + if any(x in last_error.lower() for x in ("400", "429", "rate")): + continue + break + + # Raise exception so combo fallback can try next provider + raise RuntimeError(f"OpenAI OAuth error: {last_error}") + + +def _handle_gemini_chat( + model: str, + messages: list[dict[str, Any]], + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Gemini AI Studio chat — native function calling support.""" + from services.providers.gemini_free import gemini_provider, GEMINI_DEFAULT_MODEL + + pure_model = model + for prefix in ("gemini/", "gemini_free/"): + if model.startswith(prefix): + pure_model = model[len(prefix):] + break + if not pure_model or pure_model == "auto": + # Use user's configured model from settings, fallback to default + provider_cfg = (config.data.get("providers") or {}).get("gemini_free") or {} + pure_model = str(provider_cfg.get("model") or "") or GEMINI_DEFAULT_MODEL + + logger.info({"event": "gemini_chat", "model": pure_model}) + + temperature = body.get("temperature") + max_tokens = body.get("max_tokens") + tools = body.get("tools") + tool_choice = body.get("tool_choice") + + try: + # Gemini always streams via SSE API — iterator handles both cases + result_iter = gemini_provider.chat_completions( + messages=messages, model=pure_model, + temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + ) + if stream: + return result_iter + else: + # Collect stream into single response + content = "" + tc = [] + for chunk in result_iter: + delta = chunk.get("choices", [{}])[0].get("delta", {}) + content += delta.get("content", "") + if delta.get("tool_calls"): + tc = delta["tool_calls"] + msg = {"role": "assistant", "content": content} + if tc: + msg["tool_calls"] = tc + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", "object": "chat.completion", + "created": int(time.time()), "model": pure_model, + "choices": [{"index": 0, "message": msg, "finish_reason": "stop"}], + "usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0}, + } + except Exception as exc: + logger.error({"event": "gemini_fatal", "error": str(exc)}) + return completion_response(model=model, content=f"Gemini error: {exc}", messages=messages) + + +def _handle_nvidia_chat( + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """NVIDIA NIM chat — OpenAI-compatible proxy, no format conversion needed.""" + from services.providers.nvidia_nim import nvidia_nim_provider + + pure_model = model + if model.startswith("nv/"): + pure_model = model[3:] + + logger.info({"event": "nvidia_nim_chat", "model": pure_model, "stream": stream}) + + temperature = body.get("temperature") + max_tokens = body.get("max_tokens") + + try: + result = nvidia_nim_provider.chat_completions( + messages=messages, model=pure_model, stream=stream, + temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + top_p=body.get("top_p"), + frequency_penalty=body.get("frequency_penalty"), + presence_penalty=body.get("presence_penalty"), + ) + if stream: + return result + else: + return result + except Exception as exc: + logger.error({"event": "nvidia_nim_fatal", "error": str(exc)}) + return completion_response( + model=model, + content=f"NVIDIA NIM error: {exc}", + messages=messages, + ) + + +def _handle_custom_openai_chat( + provider_key: str, + model: str, + messages: list[dict[str, Any]], + tools: list[dict[str, Any]] | None, + tool_choice: Any, + stream: bool, + body: dict[str, Any], +) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Custom OpenAI-compatible provider — generic proxy.""" + from services.providers.custom_openai import CustomOpenAIProvider, get_custom_providers + + # Extract provider ID from "custom:deepseek" format + provider_id = provider_key[len("custom:"):] + + providers = get_custom_providers() + cfg = providers.get(provider_id) + if not cfg: + return completion_response( + model=model, + content=f"Custom provider '{provider_id}' not found or disabled", + messages=messages, + ) + + provider = CustomOpenAIProvider(cfg) + + logger.info({"event": "custom_openai_chat", "provider": provider.name, "model": model}) + + temperature = body.get("temperature") + max_tokens = body.get("max_tokens") + + try: + result = provider.chat_completions( + messages=messages, model=model, stream=stream, + temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, + top_p=body.get("top_p"), + frequency_penalty=body.get("frequency_penalty"), + presence_penalty=body.get("presence_penalty"), + ) + if stream: + return result + else: + return result + except Exception as exc: + logger.error({"event": "custom_openai_fatal", "provider": provider.name, "error": str(exc)}) + return completion_response( + model=model, + content=f"[{provider.name}] Error: {exc}", + messages=messages, + ) diff --git a/services/protocol/openai_v1_image_edit.py b/services/protocol/openai_v1_image_edit.py index 8f1d94a9..e46628e5 100644 --- a/services/protocol/openai_v1_image_edit.py +++ b/services/protocol/openai_v1_image_edit.py @@ -2,14 +2,18 @@ from typing import Any, Iterator +from services.backend_router import backend_router +from services.image_providers import get_image_adapter from services.protocol.conversation import ( ConversationRequest, ImageGenerationError, collect_image_outputs, encode_images, + format_image_result, stream_image_chunks, stream_image_outputs_with_pool, ) +from utils.log import logger def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: @@ -20,6 +24,15 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: size = body.get("size") response_format = str(body.get("response_format") or "b64_json") base_url = str(body.get("base_url") or "") or None + + # Check if this is an adapter model (gemini-image, nv-image, sdwebui, etc.) + route = backend_router.parse_model(model) + adapter = get_image_adapter(route.provider) if route else None + + if adapter: + return _handle_adapter_edit(adapter, route, body, prompt, images, n, response_format, base_url) + + # Default: ChatGPT DALL-E pipeline encoded_images = encode_images(images) if not encoded_images: raise ImageGenerationError("image is required") @@ -36,3 +49,70 @@ def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: if body.get("stream"): return stream_image_chunks(outputs) return collect_image_outputs(outputs) + + +def _handle_adapter_edit(adapter, route, body, prompt, images, n, response_format, base_url): + """Handle image editing through an adapter (e.g., Gemini).""" + import json + from curl_cffi import requests as cffi_requests + from services.config import config + + provider_key = route.provider + providers_cfg = config.data.get("providers") or {} + provider_config = providers_cfg.get(provider_key) or {} + if not provider_config and provider_key == "gemini": + provider_config = providers_cfg.get("gemini_free") or {} + + credentials = { + "apiKey": str(provider_config.get("api_key") or ""), + "apiKeys": provider_config.get("api_keys") or [], + } + + max_keys = getattr(adapter, 'get_key_count', lambda c: 1)(credentials) + all_data = [] + last_error = "" + + for idx in range(n): + for key_try in range(max(max_keys, 1)): + try: + try: + url = adapter.build_url(route.model, credentials, key_try) + except TypeError: + url = adapter.build_url(route.model, credentials) + + # Pass images in body for adapter + edit_body = dict(body) + edit_body["images"] = images # raw bytes from upload + req_body = adapter.build_body(route.model, edit_body) + + resp = cffi_requests.post(url, json=req_body, timeout=300) + + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + if resp.status_code in (400, 429) and key_try < max_keys - 1: + last_error = error_text + continue + raise RuntimeError(f"Image edit failed: {route.provider} status={resp.status_code}") + + parsed = adapter.parse_response(resp) if hasattr(adapter, "parse_response") else None + if parsed is None: + try: + parsed = resp.json() + except Exception: + parsed = {"image_bytes": resp.content} + + normalized = adapter.normalize(parsed, body) + all_data.extend(normalized.get("data") or []) + break + + except Exception as exc: + logger.error({"event": "image_edit_adapter_error", "error": str(exc)}) + if key_try < max_keys - 1: + continue + raise RuntimeError(f"Image edit failed: {exc}") from exc + + return format_image_result(all_data, prompt, response_format, base_url) diff --git a/services/protocol/openai_v1_image_generations.py b/services/protocol/openai_v1_image_generations.py index 1e626148..7f444327 100644 --- a/services/protocol/openai_v1_image_generations.py +++ b/services/protocol/openai_v1_image_generations.py @@ -1,31 +1,241 @@ from __future__ import annotations +import base64 +import json from typing import Any, Iterator +from curl_cffi import requests as cffi_requests + from services.protocol.conversation import ( ConversationRequest, + ImageOutput, collect_image_outputs, + format_image_result, stream_image_chunks, stream_image_outputs_with_pool, ) +from services.backend_router import backend_router +from services.config import config +from services.image_providers import get_image_adapter, is_noauth_image_provider +from services.image_providers._base import now_sec +from utils.log import logger + + +def _handle_adapter_image(route, body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Handle image generation through an adapter (sdwebui, huggingface, etc.).""" + adapter = get_image_adapter(route.provider) + if not adapter: + # Custom providers don't have image adapters — raise to trigger combo fallback + raise RuntimeError(f"Provider '{route.provider}' does not support image generation") + + prompt = str(body.get("prompt") or "") + n = max(1, min(4, int(body.get("n") or 1))) + response_format = str(body.get("response_format") or "b64_json") + base_url_str = str(body.get("base_url") or "") or None + stream = bool(body.get("stream")) + + # Build credentials from config + provider_key = route.provider + providers_cfg = config.data.get("providers") or {} + provider_config = providers_cfg.get(provider_key) or {} + # Map image adapter key → chat provider key for credentials + if not provider_config and provider_key == "gemini": + provider_config = providers_cfg.get("gemini_free") or {} + elif not provider_config and provider_key == "nvidia_nim_image": + provider_config = providers_cfg.get("nvidia_nim") or {} + + credentials = {} + if route.no_auth: + credentials = {"accessToken": "public"} + else: + credentials = { + "apiKey": str(provider_config.get("api_key") or ""), + "apiKeys": provider_config.get("api_keys") or [], + "accessToken": str(provider_config.get("api_key") or ""), + } + + # For sdwebui, use configured base_url + if route.provider == "sdwebui": + adapter.base_url = str(provider_config.get("base_url") or "http://localhost:7860").rstrip("/") + + # Generate n images + all_data: list[dict[str, Any]] = [] + stream_outputs: list[ImageOutput] = [] + # Get key count for retry + max_keys = getattr(adapter, 'get_key_count', lambda c: 1)(credentials) + + for idx in range(n): + last_error = "" + for key_try in range(max(max_keys, 1)): + try: + # Try with key_index for adapters that support key rotation + try: + url = adapter.build_url(route.model, credentials, key_try) + except TypeError: + url = adapter.build_url(route.model, credentials) + req_body = adapter.build_body(route.model, body) + headers = adapter.build_headers(credentials, req_body, route.model, body) + + logger.info({ + "event": "image_adapter_request", + "provider": route.provider, + "model": route.model, + "url": url[:120], + "key_try": key_try, + }) + + resp = cffi_requests.post( + url, + headers=headers, + json=req_body, + timeout=300, + ) + + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + if resp.status_code in (400, 429) and key_try < max_keys - 1: + logger.warning({ + "event": "image_adapter_retry", + "provider": route.provider, + "status": resp.status_code, + "key_try": key_try, + "error": error_text[:200], + }) + last_error = error_text + continue # try next key + logger.error({ + "event": "image_adapter_error", + "provider": route.provider, + "status": resp.status_code, + "error": error_text, + }) + raise RuntimeError(f"Image generation failed: {route.provider} status={resp.status_code}") + + # Try custom parse_response first (async adapters) + parsed = adapter.parse_response(resp) if hasattr(adapter, "parse_response") else None + + if parsed is None: + # Default: parse JSON + normalize + try: + raw_json = resp.json() + except Exception: + # Binary response (image bytes) + raw_json = {"image_bytes": resp.content} + parsed = raw_json + + normalized = adapter.normalize(parsed, body) + data_items = normalized.get("data") or [] + all_data.extend(data_items) + + if stream: + stream_outputs.append(ImageOutput( + kind="result", + model=body.get("model", "unknown"), + index=idx + 1, + total=n, + data=data_items, + )) + break # success — stop trying keys + + except Exception as exc: + logger.error({"event": "image_adapter_fatal", "provider": route.provider, "error": str(exc)}) + if key_try < max_keys - 1: + continue # try next key + raise RuntimeError(f"Image generation failed: {exc}") from exc + + if stream and stream_outputs: + # Yield stream chunks + def _stream(): + for output in stream_outputs: + yield output.to_chunk() + return _stream() + + # Non-streaming response + result = format_image_result( + all_data, + prompt, + response_format, + base_url_str, + ) + if not result.get("data"): + result["message"] = "Image generation completed but no images returned." + return result 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") + size = body.get("size") or config.default_image_size # configurable default (16:9) response_format = str(body.get("response_format") or "b64_json") - base_url = str(body.get("base_url") or "") or None + base_url_str = str(body.get("base_url") or "") or None + stream = bool(body.get("stream")) + + # Combo model support — try each model in the combo until one succeeds + if backend_router.is_combo(model): + routes = backend_router.route_combo(model) + last_error = "" + for route in routes: + try: + # Try all models in combo: image models + custom providers (may support image gen) + if route.is_image or route.provider == "chatgpt" or route.provider.startswith("custom:"): + return _handle_single_image(route, body) + except Exception as exc: + last_error = str(exc) + logger.warning({ + "event": "image_combo_fallback", + "model": route.model, + "error": last_error, + }) + continue + raise RuntimeError(f"All image models in combo '{model}' failed: {last_error}") + + # Single model routing + route = backend_router.route(model) + return _handle_single_image(route, body) + + +def _handle_single_image(route, body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Handle image generation for a single model (adapter or ChatGPT DALL-E).""" + 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") or config.default_image_size # configurable default (16:9) + response_format = str(body.get("response_format") or "b64_json") + base_url_str = str(body.get("base_url") or "") or None + stream = bool(body.get("stream")) + + # If routed to a non-ChatGPT image provider, use adapter + if route.provider != "chatgpt" and (route.is_image or route.provider.startswith("custom:")): + logger.info({ + "event": "image_routed_to_adapter", + "provider": route.provider, + "model": route.model, + }) + try: + return _handle_adapter_image(route, body) + except Exception as exc: + logger.warning({ + "event": "image_adapter_fallback", + "provider": route.provider, + "error": str(exc), + }) + raise # Re-raise to trigger combo fallback + + # Default: use existing ChatGPT DALL-E flow (unchanged) outputs = stream_image_outputs_with_pool(ConversationRequest( prompt=prompt, - model=model, + model=route.model if route.model != "auto" else model, n=n, size=size, response_format=response_format, - base_url=base_url, + base_url=base_url_str, message_as_error=True, )) - if body.get("stream"): + if stream: return stream_image_chunks(outputs) return collect_image_outputs(outputs) diff --git a/services/protocol/openai_v1_models.py b/services/protocol/openai_v1_models.py index 49404e93..c3eb45cc 100644 --- a/services/protocol/openai_v1_models.py +++ b/services/protocol/openai_v1_models.py @@ -1,26 +1,521 @@ from __future__ import annotations +import json +import time +from concurrent.futures import ThreadPoolExecutor, as_completed from typing import Any +from curl_cffi import requests + +from services.account_service import account_service from services.openai_backend_api import OpenAIBackendAPI -from utils.helper import IMAGE_MODELS +from services.config import config, DATA_DIR +from utils.helper import IMAGE_MODELS, anonymize_token +from utils.log import logger + + +# Fallback static models — used when API fetch fails +FALLBACK_MODELS = { + "opencode": [ + "oc/auto", + "oc/nemotron-3-super-free", + "oc/minimax-m2.5-free", + "oc/ring-2.6-1t-free", + "oc/trinity-large-preview-free", + ], + "gemini_free": [ + "gemini_free/auto", + "gemini_free/gemini-2.5-flash", + ], + "openai_oauth": [ + "cx/auto", + ], + "nvidia_nim": [ + "nv/auto", + "nv-image/black-forest-labs/flux.2-klein-4b", + "nv-image/black-forest-labs/flux.1-dev", + "nv-image/black-forest-labs/flux_1-schnell", + "nv-image/stabilityai/stable-diffusion-3-medium", + "nv-image/stabilityai/stable-diffusion-xl", + ], + "chatgpt2api": [], +} + +GEMINI_MODELS_URL = "https://generativelanguage.googleapis.com/v1beta/models" +OPENCODE_MODELS_URL = "https://opencode.ai/zen/v1/models" +OPENROUTER_MODELS_URL = "https://openrouter.ai/api/v1/models" +NVIDIA_MODELS_URL = "https://integrate.api.nvidia.com/v1/models" + + +def _get_gemini_keys() -> list[str]: + cfg = config.data.get("providers") or {} + gemini_cfg = cfg.get("gemini_free") or {} + single = str(gemini_cfg.get("api_key") or "").strip() + multi = gemini_cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + +def _fetch_gemini_models() -> set[str]: + """Fetch available models from Gemini API. Returns set of model IDs with gemini_free/ prefix.""" + keys = _get_gemini_keys() + if not keys: + logger.info({"event": "list_models_gemini_skip", "reason": "no_api_key"}) + return set() + + for key in keys: + try: + resp = requests.get(f"{GEMINI_MODELS_URL}?key={key}", timeout=10) + if resp.status_code != 200: + continue + models = set() + for item in resp.json().get("models", []): + name = str(item.get("name", "")).replace("models/", "") + methods = item.get("supportedGenerationMethods") or [] + if "generateContent" in methods: + models.add(f"gemini_free/{name}") + if models: + logger.info({"event": "list_models_gemini_fetched", "count": len(models)}) + return models + except Exception as exc: + logger.warning({"event": "list_models_gemini_error", "error": str(exc)}) + continue + + return set() + + +def _fetch_opencode_models() -> set[str]: + """Fetch available free models from OpenCode API. Returns set of model IDs with oc/ prefix.""" + try: + resp = requests.get( + OPENCODE_MODELS_URL, + headers={"Authorization": "Bearer public", "x-opencode-client": "desktop"}, + timeout=15, + ) + if resp.status_code != 200: + logger.warning({"event": "list_models_opencode_failed", "status": resp.status_code}) + return set() + + models = set() + for item in resp.json().get("data", []): + slug = str(item.get("id") or "").strip() + if not slug: + continue + # Only include free models (ending in -free) + if slug.endswith("-free"): + models.add(f"oc/{slug}") + if models: + logger.info({"event": "list_models_opencode_fetched", "count": len(models)}) + return models + except Exception as exc: + logger.warning({"event": "list_models_opencode_error", "error": str(exc)}) + + return set() + + +def _fetch_openrouter_models() -> set[str]: + """Fetch available models from OpenRouter API. Returns set of model IDs with openrouter/ prefix.""" + cfg = config.data.get("providers") or {} + or_cfg = cfg.get("openrouter") or {} + api_key = str(or_cfg.get("api_key") or "").strip() + if not api_key: + return set() + + try: + resp = requests.get( + OPENROUTER_MODELS_URL, + headers={"Authorization": f"Bearer {api_key}"}, + timeout=15, + ) + if resp.status_code != 200: + logger.warning({"event": "list_models_openrouter_failed", "status": resp.status_code}) + return set() + + models = set() + for item in resp.json().get("data", []): + slug = str(item.get("id") or "").strip() + if slug: + models.add(f"openrouter/{slug}") + if models: + logger.info({"event": "list_models_openrouter_fetched", "count": len(models)}) + return models + except Exception as exc: + logger.warning({"event": "list_models_openrouter_error", "error": str(exc)}) + + return set() + + +def _fetch_nvidia_models() -> set[str]: + """Fetch available models from NVIDIA NIM API. Returns set of model IDs with nv/ prefix.""" + cfg = config.data.get("providers") or {} + nv_cfg = cfg.get("nvidia_nim") or {} + single = str(nv_cfg.get("api_key") or "").strip() + multi = nv_cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + + if not keys: + logger.info({"event": "list_models_nvidia_skip", "reason": "no_api_key"}) + return set() + + for key in keys: + try: + resp = requests.get( + f"{NVIDIA_MODELS_URL}", + headers={"Authorization": f"Bearer {key}"}, + timeout=15, + ) + if resp.status_code != 200: + continue + models = set() + for item in resp.json().get("data", []): + slug = str(item.get("id") or "").strip() + if slug: + models.add(f"nv/{slug}") + + # NVIDIA image gen models are on a separate API (ai.api.nvidia.com/v1/genai/) + # There's no list endpoint, so we hardcode known models with nv-image/ prefix + nv_image_models = [ + "nv-image/black-forest-labs/flux.2-klein-4b", + "nv-image/black-forest-labs/flux.1-dev", + "nv-image/black-forest-labs/flux_1-schnell", + "nv-image/stabilityai/stable-diffusion-3-medium", + "nv-image/stabilityai/stable-diffusion-xl", + "nv-image/stabilityai/stable-video-diffusion", + ] + cfg = config.data.get("providers") or {} + nv_cfg = cfg.get("nvidia_nim") or {} + if nv_cfg.get("enabled", True): + models.update(nv_image_models) + + if models: + logger.info({"event": "list_models_nvidia_fetched", "count": len(models)}) + return models + except Exception as exc: + logger.warning({"event": "list_models_nvidia_error", "error": str(exc)}) + continue + + return set() + + +def _fetch_chatgpt_token_models() -> set[str]: + """Fetch models from all ChatGPT tokens, compute intersection.""" + active_tokens: list[str] = [] + with account_service._lock: + for token, account in account_service._accounts.items(): + if not token or token == "public": + continue + status = str(account.get("status") or "") + if status in {"disabled", "error"}: + continue + if str(account.get("type") or "") == "codex" and token.startswith("eyJ"): + continue + active_tokens.append(token) + + if not active_tokens: + # Fallback: anon + try: + result = OpenAIBackendAPI().list_models() + models = set() + for item in result.get("data", []): + if isinstance(item, dict) and item.get("id"): + models.add(str(item["id"])) + logger.info({"event": "list_models_chatgpt_anon", "count": len(models)}) + return models + except Exception as exc: + logger.warning({"event": "list_models_anon_failed", "error": str(exc)}) + return set() + + # Parallel fetch from all tokens + token_sets: list[set[str]] = [] + with ThreadPoolExecutor(max_workers=min(8, len(active_tokens))) as executor: + futures = { + executor.submit(_fetch_models_for_token, token): token + for token in active_tokens + } + for future in as_completed(futures): + _, model_set = future.result() + if model_set is not None: + token_sets.append(model_set) + + if not token_sets: + return set() + + # Intersection + common = token_sets[0].copy() + for s in token_sets[1:]: + common &= s + + logger.info({ + "event": "list_models_chatgpt_intersection", + "total_tokens": len(active_tokens), + "successful": len(token_sets), + "common_count": len(common), + }) + return common + +def _fetch_models_for_token(token: str) -> tuple[str, set[str] | None]: + try: + api = OpenAIBackendAPI(access_token=token) + result = api.list_models() + models = set() + for item in result.get("data", []): + if isinstance(item, dict) and item.get("id"): + models.add(str(item["id"])) + return (anonymize_token(token), models) + except Exception as exc: + logger.warning({"event": "list_models_token_failed", "token": anonymize_token(token), "error": str(exc)}) + return (anonymize_token(token), None) -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)} + +def _apply_fallback(provider: str) -> set[str]: + """Get fallback models for a provider when API fetch fails.""" + return set(FALLBACK_MODELS.get(provider, [])) + + +# ── Persistent model cache (disk) ── +import os as _os +_CACHE_FILE = DATA_DIR / "models_cache.json" +_models_cache: dict[str, Any] | None = None +_cache_config_hash: str = "" + + +def _load_cache_from_disk() -> dict[str, Any] | None: + """Load cached model list from disk. Returns None if not found or corrupted.""" + try: + if _CACHE_FILE.exists(): + data = json.loads(_CACHE_FILE.read_text(encoding="utf-8")) + if isinstance(data, dict) and "data" in data: + # Verify cache consistency: combo_models in cache should match config + cache_combos = set() + for m in data.get("data", []): + mid = str(m.get("id") or "") + # Check if this is a combo model (owned_by == "chatgpt2api" and in combos) + if m.get("owned_by") == "chatgpt2api": + cache_combos.add(mid) + current_combos = set((config.data.get("combo_models") or {}).keys()) + if cache_combos != current_combos: + logger.info({"event": "models_cache_stale_combos", "cache": list(cache_combos), "config": list(current_combos)}) + return None + logger.info({"event": "models_cache_disk_loaded", "count": len(data.get("data", []))}) + return data + except Exception: + pass + return None + + +def _save_cache_to_disk(data: dict[str, Any]): + """Persist model list to disk.""" + try: + _CACHE_FILE.parent.mkdir(parents=True, exist_ok=True) + _CACHE_FILE.write_text(json.dumps(data, ensure_ascii=False), encoding="utf-8") + except Exception as exc: + logger.warning({"event": "models_cache_save_error", "error": str(exc)}) + + +def _config_hash() -> str: + import hashlib, json as _json + raw = _json.dumps({ + "cp": config.data.get("custom_providers") or {}, + "ms": config.data.get("model_settings") or {}, + }, sort_keys=True, default=str) + return hashlib.md5(raw.encode()).hexdigest() + + +def invalidate_models_cache(): + """Clear cache when config changes. Call from API endpoints after save.""" + global _models_cache, _cache_config_hash + _models_cache = None + _cache_config_hash = "" + try: + if _CACHE_FILE.exists(): + _CACHE_FILE.unlink() + except Exception: + pass + + +def list_models(force_refresh: bool = False, apply_filter: bool = False) -> dict[str, Any]: + """Return available models — cached to disk. apply_filter=True for HA /v1/models.""" + global _models_cache, _cache_config_hash + + # Load from disk on first access + if _models_cache is None: + _models_cache = _load_cache_from_disk() + _cache_config_hash = _config_hash() + + current_hash = _config_hash() + config_changed = _cache_config_hash != current_hash + + # Use cache if: loaded from disk, not forced, and config hasn't changed + if _models_cache is not None and not force_refresh and not config_changed: + logger.info({"event": "list_models_cache_hit"}) + return dict(_models_cache) # type: ignore[arg-type] + + if force_refresh: + logger.info({"event": "list_models_manual_refresh"}) + elif config_changed: + logger.info({"event": "list_models_config_changed"}) + else: + logger.info({"event": "list_models_first_load"}) + + data: list[dict[str, Any]] = [] + + # Fetch from all built-in providers in parallel + provider_fetchers = { + "chatgpt": _fetch_chatgpt_token_models, + "gemini_free": _fetch_gemini_models, + "opencode": _fetch_opencode_models, + "openrouter": _fetch_openrouter_models, + "nvidia_nim": _fetch_nvidia_models, + } + + # Add custom providers dynamically + from services.providers.custom_openai import get_custom_providers, CustomOpenAIProvider + custom_providers = get_custom_providers() + for cp_id, cp_cfg in custom_providers.items(): + def make_fetcher(cfg=cp_cfg): + provider = CustomOpenAIProvider(cfg) + def fetcher(): + models = set() + for m in provider.list_models(): + mid = str(m.get("id") or "").strip() + if mid: + models.add(mid) + if models: + logger.info({"event": "list_models_custom", "provider": provider.name, "count": len(models)}) + return models + return fetcher + provider_fetchers[f"custom:{cp_id}"] = make_fetcher() + + all_models: dict[str, set[str]] = {} + with ThreadPoolExecutor(max_workers=len(provider_fetchers)) as executor: + futures = { + executor.submit(fetcher): name + for name, fetcher in provider_fetchers.items() + } + for future in as_completed(futures): + name = futures[future] + try: + models = future.result() + if models: + all_models[name] = models + except Exception as exc: + logger.warning({"event": "list_models_provider_failed", "provider": name, "error": str(exc)}) + + # Build seen set to avoid duplicates + seen: set[str] = set() + + # Add dynamically fetched models + for provider_name, models in sorted(all_models.items()): + for model_id in sorted(models): + if model_id not in seen: + seen.add(model_id) + data.append({ + "id": model_id, + "object": "model", + "created": 0, + "owned_by": provider_name, + }) + + # Apply fallbacks for providers that returned nothing + for provider_name in ["opencode", "gemini_free", "openai_oauth", "nvidia_nim", "chatgpt2api"]: + if provider_name not in all_models: + for model_id in sorted(_apply_fallback(provider_name)): + if model_id not in seen: + seen.add(model_id) + data.append({ + "id": model_id, + "object": "model", + "created": 0, + "owned_by": provider_name, + }) + + # Add image models for model in sorted(IMAGE_MODELS): if model not in seen: + seen.add(model) data.append({ - "id": model, - "object": "model", - "created": 0, - "owned_by": "chatgpt2api", - "permission": [], - "root": model, - "parent": None, + "id": model, "object": "model", "created": 0, + "owned_by": "chatgpt2api", "permission": [], + "root": model, "parent": None, }) + + # Add combo models from config + combos = config.data.get("combo_models") or {} + if isinstance(combos, dict): + for combo_name in combos: + if combo_name not in seen: + seen.add(combo_name) + data.append({ + "id": combo_name, "object": "model", "created": 0, + "owned_by": "chatgpt2api", + }) + + # Add image provider models from config + providers = config.data.get("providers") or {} + if isinstance(providers, dict): + for provider_name, provider_cfg in providers.items(): + if isinstance(provider_cfg, dict) and provider_cfg.get("enabled"): + provider_id = f"{provider_name}/auto" + if provider_id not in seen and provider_name not in ("gemini_free", "openrouter", "serper", "searxng", "brave"): + seen.add(provider_id) + data.append({ + "id": provider_id, "object": "model", "created": 0, + "owned_by": provider_name, + }) + + # Apply model_settings filter — only if requested (for HA /v1/models, not for UI) + if apply_filter: + model_settings = config.data.get("model_settings") or {} + if isinstance(model_settings, dict): + enabled_by_provider = model_settings.get("enabled_models") or {} + if isinstance(enabled_by_provider, dict) and enabled_by_provider: + # Build a flat set of all explicitly enabled model IDs + all_enabled: set[str] = set() + for provider_models in enabled_by_provider.values(): + if isinstance(provider_models, list): + for m in provider_models: + if isinstance(m, str) and m.strip(): + all_enabled.add(m.strip()) + + # Also always allow special models: combos, image models, auto variants + always_allow = { + "cx/auto", "oc/auto", "chatgpt/auto", "gemini_free/auto", + } + all_enabled |= always_allow + + # Add combo model names from config + combos = config.data.get("combo_models") or {} + if isinstance(combos, dict): + all_enabled |= set(combos.keys()) + + # Add image models + all_enabled |= set(IMAGE_MODELS) + + # Filter + before = len(data) + data = [item for item in data if str(item.get("id") or "").strip() in all_enabled] + logger.info({ + "event": "list_models_filtered", + "before": before, + "after": len(data), + "enabled_rules": len(enabled_by_provider), + }) + + logger.info({"event": "list_models_done", "total_models": len(data)}) + # Save to persistent disk cache + result = {"object": "list", "data": data} + _models_cache = result + _cache_config_hash = current_hash + _save_cache_to_disk(result) + logger.info({"event": "list_models_cached_to_disk", "total": len(data)}) + return result diff --git a/services/protocol/openai_v1_response.py b/services/protocol/openai_v1_response.py index 32cbf0ac..bb24aac8 100644 --- a/services/protocol/openai_v1_response.py +++ b/services/protocol/openai_v1_response.py @@ -134,13 +134,19 @@ def response_completed(response_id: str, model: str, created: int, output: list[ def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]: model = str(body.get("model") or "auto").strip() or "auto" messages = messages_from_input(body.get("input"), body.get("instructions")) + tools = body.get("tools") + if isinstance(tools, list) and tools: + tools = [t for t in tools if isinstance(t, dict)] + else: + tools = None + tool_choice = body.get("tool_choice") 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) + request = ConversationRequest(model=model, messages=messages, tools=tools, tool_choice=tool_choice) 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} diff --git a/services/protocol/stream_filter.py b/services/protocol/stream_filter.py new file mode 100644 index 00000000..8da3214d --- /dev/null +++ b/services/protocol/stream_filter.py @@ -0,0 +1,27 @@ +import re +from typing import Iterator +from services.protocol.conversation import CITATION_RE + +def filter_streamed_text(text_iterator: Iterator[str]) -> Iterator[str]: + """ + A sliding window stream filter to safely strip ChatGPT citations (citeturn...). + It buffers a small amount of text to prevent cutting the citation marker in half. + """ + buffer = "" + for chunk in text_iterator: + buffer += chunk + + # Apply stripping to the buffer + buffer = CITATION_RE.sub("", buffer) + buffer = re.sub(r'[^\s]*citeturn[^\s]*', '', buffer, flags=re.IGNORECASE) + + # Yield everything except the last 30 characters (which might be a partial 'citeturn') + if len(buffer) > 50: + yield_text = buffer[:-30] + buffer = buffer[-30:] + yield yield_text + + if buffer: + buffer = CITATION_RE.sub("", buffer) + buffer = re.sub(r'[^\s]*citeturn[^\s]*', '', buffer, flags=re.IGNORECASE) + yield buffer diff --git a/services/providers/__init__.py b/services/providers/__init__.py new file mode 100644 index 00000000..4a0e458c --- /dev/null +++ b/services/providers/__init__.py @@ -0,0 +1,5 @@ +"""AI provider adapters — ported from 9router.""" + +from services.providers.opencode import OpenCodeProvider + +__all__ = ["OpenCodeProvider"] diff --git a/services/providers/custom_openai.py b/services/providers/custom_openai.py new file mode 100644 index 00000000..06823e4a --- /dev/null +++ b/services/providers/custom_openai.py @@ -0,0 +1,349 @@ +""" +Custom OpenAI-compatible Provider — generic proxy for any OpenAI-compatible API. + +Users can add custom APIs via UI (Settings → Custom Providers) without writing code. +Each provider gets a unique prefix. Models are auto-fetched from {base_url}/v1/models. + +Config stored in config.data["custom_providers"]: +{ + "deepseek": { + "name": "DeepSeek", + "base_url": "https://api.deepseek.com", + "api_key": "sk-...", + "prefix": "deepseek", + "enabled": true + } +} +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Iterator + +from curl_cffi import requests + +from services.config import config +from utils.log import logger + + +def get_custom_providers() -> dict[str, dict[str, Any]]: + """Get all enabled custom providers from config.""" + providers = config.data.get("custom_providers") or {} + if not isinstance(providers, dict): + return {} + return { + k: v for k, v in providers.items() + if isinstance(v, dict) and v.get("enabled", True) + } + + +def resolve_custom_provider(model: str) -> tuple[dict[str, Any] | None, str]: + """Check if model matches any custom provider prefix. + Returns (provider_config, stripped_model) or (None, original_model). + """ + for provider_id, cfg in get_custom_providers().items(): + prefix = str(cfg.get("prefix") or provider_id).strip() + if not prefix: + continue + full_prefix = f"{prefix}/" + if model.startswith(full_prefix): + return (cfg, model[len(full_prefix):]) + return (None, model) + + +class CustomOpenAIProvider: + """Generic OpenAI-compatible provider — proxies to any OpenAI-compatible endpoint.""" + + def __init__(self, provider_config: dict[str, Any]): + self.cfg = provider_config + self.base_url = str(provider_config.get("base_url") or "").rstrip("/") + self.name = str(provider_config.get("name") or "Custom") + self._key_index = 0 + self._rate_limited: dict[str, float] = {} + + def _get_keys(self) -> list[str]: + """Get all configured API keys (supports multi-key).""" + single = str(self.cfg.get("api_key") or "").strip() + multi = self.cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + @property + def api_key(self) -> str: + keys = self._get_keys() + if not keys: + return "" + now = time.time() + for _ in range(len(keys)): + key = keys[self._key_index % len(keys)] + self._key_index += 1 + if self._rate_limited.get(key, 0) < now: + return key + return min(keys, key=lambda k: self._rate_limited.get(k, 0)) + + @property + def is_available(self) -> bool: + if not self.base_url or not self.api_key: + return False + try: + resp = requests.get( + f"{self.base_url}/v1/models", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=10, + ) + return resp.status_code == 200 + except Exception: + return False + + def chat_completions( + self, + messages: list[dict[str, Any]], + model: str = "", + stream: bool = False, + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **kwargs, + ) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Forward chat request to custom API endpoint.""" + if not self.base_url: + raise RuntimeError(f"Custom provider '{self.name}' has no base URL configured") + if not self.api_key: + raise RuntimeError(f"Custom provider '{self.name}' has no API key configured") + + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": stream, + } + if temperature is not None: + body["temperature"] = temperature + if max_tokens: + body["max_tokens"] = max_tokens + if tools: + body["tools"] = tools + if tool_choice: + body["tool_choice"] = tool_choice + + # Pass through common extra params + for key in ("top_p", "frequency_penalty", "presence_penalty", "seed", "response_format"): + if key in kwargs and kwargs[key] is not None: + body[key] = kwargs[key] + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if stream: + headers["Accept"] = "text/event-stream" + + logger.info({ + "event": "custom_provider_request", + "provider": self.name, + "base_url": self.base_url, + "model": model, + "stream": stream, + }) + + try: + resp = requests.post( + f"{self.base_url}/v1/chat/completions", + headers=headers, + json=body, + timeout=300, + stream=stream, + ) + + if resp.status_code == 429: + # Rate limited — mark key and retry with next + current_key = self.api_key + self._rate_limited[current_key] = time.time() + 60 + attempted = getattr(self, "_attempted_keys", set()) + attempted.add(current_key) + self._attempted_keys = attempted + if len(attempted) < len(self._get_keys()): + return self.chat_completions( + messages=messages, model=model, stream=stream, + temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, **kwargs, + ) + raise RuntimeError(f"[{self.name}] All API keys rate limited") + + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + logger.error({ + "event": "custom_provider_error", + "provider": self.name, + "status": resp.status_code, + "error": error_text, + }) + raise RuntimeError(f"[{self.name}] Error {resp.status_code}: {error_text[:200]}") + + if stream: + return self._stream_response(resp, model) + else: + return self._non_stream_response(resp, model) + + except requests.RequestsError as exc: + raise RuntimeError(f"[{self.name}] Connection failed: {exc}") from exc + + def _stream_response(self, response, model: str) -> Iterator[dict[str, Any]]: + """Parse SSE stream → OpenAI chunks (passthrough — already OpenAI format).""" + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + + try: + 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) + line = line.strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + + # Pass through OpenAI-format chunks as-is (they're already correct) + choices = chunk.get("choices") or [] + for choice in choices: + delta = choice.get("delta") or {} + content = delta.get("content") + if content: + if not sent_role: + sent_role = True + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + # Yield the chunk as-is with our IDs + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{ + "index": 0, + "delta": delta, + "finish_reason": choice.get("finish_reason"), + }], + } + + except Exception as exc: + logger.error({"event": "custom_provider_stream_error", "provider": self.name, "error": str(exc)}) + + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + + def _non_stream_response(self, response, model: str) -> dict[str, Any]: + """Handle non-streaming response (passthrough).""" + data = response.json() + # Return as-is — already OpenAI format + return data + + def list_models(self) -> list[dict[str, Any]]: + """Fetch available models from custom API, prefixed with provider prefix. + + If /v1/models returns empty, falls back to probing /v1/chat/completions + with a fake model name and parsing the error message for available models. + """ + if not self.base_url or not self.api_key: + return [] + + prefix = str(self.cfg.get("prefix") or "").strip() + if not prefix: + return [] + + models: list[dict[str, Any]] = [] + + # Try standard /v1/models endpoint first + try: + resp = requests.get( + f"{self.base_url}/v1/models", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=15, + ) + if resp.status_code == 200: + data = resp.json().get("data", []) + if isinstance(data, list) and data: + for item in data: + slug = str(item.get("id") or "").strip() + if slug: + if slug.startswith(f"{prefix}/"): + display_id = slug + else: + display_id = f"{prefix}/{slug}" + models.append({ + "id": display_id, + "object": "model", + "created": item.get("created", 0), + "owned_by": str(item.get("owned_by") or self.name), + }) + except Exception: + pass + + # Fallback: if models list is empty, probe chat endpoint to discover models + if not models: + try: + import re + resp = requests.post( + f"{self.base_url}/v1/chat/completions", + headers={ + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + }, + json={ + "model": "__discover_models__", + "messages": [{"role": "user", "content": "hi"}], + "max_tokens": 1, + }, + timeout=15, + ) + detail = "" + try: + detail = resp.json().get("detail", "") + except Exception: + detail = resp.text[:500] if resp.text else "" + + # Parse: "Available models: model1, model2, model3" + if isinstance(detail, str) and "Available models:" in detail: + parts = detail.split("Available models:", 1)[1].strip().rstrip(".") + found_models = [m.strip() for m in parts.split(",") if m.strip()] + for slug in found_models: + if slug and slug != "unspecified": + display_id = f"{prefix}/{slug}" + models.append({ + "id": display_id, + "object": "model", + "created": 0, + "owned_by": self.name, + }) + if models: + logger.info({ + "event": "custom_provider_models_fallback", + "provider": self.name, + "count": len(models), + }) + except Exception: + pass + + return models diff --git a/services/providers/gemini_free.py b/services/providers/gemini_free.py new file mode 100644 index 00000000..a8fba465 --- /dev/null +++ b/services/providers/gemini_free.py @@ -0,0 +1,284 @@ +""" +Gemini AI Studio Provider — Google Gemini API with native function calling. + +Free tier: 15 RPM, 1M tokens/day on gemini-2.5-flash via AI Studio. +Paid: unlimited via Google Cloud billing. +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Iterator + +from curl_cffi import requests + +from services.config import config +from utils.log import logger + +GEMINI_BASE_URL = "https://generativelanguage.googleapis.com/v1beta" +GEMINI_DEFAULT_MODEL = "gemini-3-flash-preview" + + +class GeminiProvider: + """Google Gemini API provider — free & paid tiers, native tool calling.""" + + @property + def api_key(self) -> str: + """Get next available API key (round-robin).""" + keys = self._get_keys() + if not keys: + return "" + key = keys[self._key_index % len(keys)] + self._key_index += 1 + return key + + def _get_keys(self) -> list[str]: + cfg = config.data.get("providers") or {} + gemini_cfg = cfg.get("gemini_free") or {} + single = str(gemini_cfg.get("api_key") or "").strip() + multi = gemini_cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + def __init__(self): + self._key_index = 0 + self._rate_limited: dict[str, float] = {} + + @property + def is_available(self) -> bool: + if not self.api_key: + return False + try: + resp = requests.get(f"{GEMINI_BASE_URL}/models?key={self.api_key}", timeout=10) + return resp.status_code == 200 + except Exception: + return False + + def chat_completions( + self, messages, model=GEMINI_DEFAULT_MODEL, stream=False, + temperature=None, max_tokens=None, tools=None, tool_choice=None, **kwargs, + ) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Send chat request to Gemini API with native tool calling support.""" + if not self.api_key: + raise RuntimeError("Gemini API key not configured") + + # Clear attempted keys for this request + self._attempted_keys: set = set() + + contents, system_instruction, gemini_tools = _convert_request(messages, tools) + + body: dict[str, Any] = { + "contents": contents, + "generationConfig": {}, + } + if system_instruction: + body["systemInstruction"] = system_instruction + if gemini_tools: + body["tools"] = gemini_tools + body["toolConfig"] = {"functionCallingConfig": {"mode": "AUTO"}} + if temperature is not None: + body["generationConfig"]["temperature"] = temperature + if max_tokens: + body["generationConfig"]["maxOutputTokens"] = max_tokens + + url = f"{GEMINI_BASE_URL}/models/{model}:streamGenerateContent?alt=sse" + + logger.info({"event": "gemini_request", "model": model, "has_tools": bool(gemini_tools)}) + + try: + resp = requests.post( + url, headers={"Content-Type": "application/json", "x-goog-api-key": self.api_key}, + json=body, timeout=300, stream=True, + ) + if resp.status_code == 429: + # Mark this key and retry with next one + self._rate_limited[self.api_key] = time.time() + 60 + next_key = self.api_key # _api_key rotates via property + if next_key and next_key not in getattr(self, '_attempted_keys', set()): + if not hasattr(self, '_attempted_keys'): + self._attempted_keys: set = set() + self._attempted_keys.add(self.api_key) + if len(self._attempted_keys) < len(self._get_keys()): + return self.chat_completions(messages=messages, model=model, tools=tools, **kwargs) + raise RuntimeError("All Gemini API keys rate limited. Try again later.") + if resp.status_code != 200: + raise RuntimeError(f"Gemini error {resp.status_code}: {resp.text[:200]}") + return _parse_gemini_stream(resp, model) + except requests.RequestsError as exc: + raise RuntimeError(f"Gemini connection failed: {exc}") from exc + + +def _convert_request(messages, tools): + """Convert OpenAI format → Gemini format with vision + video support.""" + import base64 as b64 + + VIDEO_EXTENSIONS = {".mp4", ".mov", ".avi", ".webm", ".wmv", ".mpeg", ".mpg", ".3gpp", ".flv", ".mkv"} + + def _is_video_url(url: str) -> bool: + """Check if URL points to a video file.""" + import os + # Check extension + for ext in VIDEO_EXTENSIONS: + if ext in url.lower().split("?")[0]: + return True + return False + + def _data_url_part(data_url: str, ptype: str = "image") -> dict | None: + """Parse data: URL → Gemini inlineData part.""" + if not data_url.startswith("data:"): + return None + header, data = data_url.split(",", 1) + mime = header.split(";")[0].replace("data:", "") + return {"inlineData": {"mimeType": mime, "data": data}} + + def _fetch_url_part(url: str) -> dict | None: + """Fetch URL and return Gemini inlineData part.""" + try: + from curl_cffi import requests as cffi_requests + resp = cffi_requests.get(url, timeout=60, impersonate="chrome110") + if resp.status_code == 200: + img_data = b64.b64encode(resp.content).decode() + ctype = resp.headers.get("content-type", "image/jpeg") + return {"inlineData": {"mimeType": ctype, "data": img_data}} + except Exception: + pass + return None + + contents = [] + system_parts = [] + + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + parts = [] + + if isinstance(content, list): + text_content = "" + for p in content: + if not isinstance(p, dict): + continue + ptype = p.get("type", "") + if ptype == "text": + text_content += p.get("text", "") + " " + elif ptype in ("image_url", "video_url"): + media_url = (p.get("image_url") or p.get("video_url") or {}).get("url", "") + if not media_url: + continue + part = _data_url_part(media_url) + if not part: + part = _fetch_url_part(media_url) + if part: + parts.append(part) + elif ptype == "file_data": + fd = p.get("file_data", {}) + file_uri = fd.get("file_uri", "") + if file_uri.startswith("data:"): + part = _data_url_part(file_uri) + elif file_uri.startswith("http"): + part = _fetch_url_part(file_uri) + if part: + parts.append(part) + if text_content.strip(): + parts.insert(0, {"text": text_content.strip()}) + content = text_content.strip() + else: + content = str(content or "") + parts = [{"text": content}] + + if role == "system": + system_parts.append(content) + continue + + if not parts: + parts = [{"text": content}] + contents.append({"role": "user" if role == "user" else "model", "parts": parts}) + + si = {"parts": [{"text": "\n".join(system_parts)}]} if system_parts else None + + gtools = None + if tools: + decls = [{"name": t.get("function", {}).get("name", ""), + "description": t.get("function", {}).get("description", ""), + "parameters": t.get("function", {}).get("parameters", {})} for t in tools] + if decls: + gtools = [{"functionDeclarations": decls}] + + return contents, si, gtools + + +def _parse_gemini_stream(response, model: str) -> Iterator[dict[str, Any]]: + """Parse Gemini SSE stream → OpenAI chunks with native tool_calls support.""" + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + accumulated_text = "" + pending_tool_calls: list[dict] = [] + + try: + 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[6:] + try: + event = json.loads(payload) + except json.JSONDecodeError: + continue + + candidates = event.get("candidates") or [] + for c in candidates: + content = c.get("content") or {} + parts = content.get("parts") or [] + for part in parts: + # Text response + text = part.get("text", "") + if text: + accumulated_text += text + if not sent_role: + sent_role = True + yield {"id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}]} + yield {"id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"content": text}, "finish_reason": None}]} + + # Function call response (native!) + func_call = part.get("functionCall") + if func_call: + pending_tool_calls.append({ + "id": f"call_{uuid.uuid4().hex[:12]}", + "type": "function", + "function": { + "name": func_call.get("name", ""), + "arguments": json.dumps(func_call.get("args", {}), ensure_ascii=False), + }, + }) + + # Yield tool calls if any + if pending_tool_calls: + yield {"id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"tool_calls": pending_tool_calls}, "finish_reason": None}]} + + except Exception as exc: + logger.error({"event": "gemini_stream_error", "error": str(exc)}) + + if not sent_role and not pending_tool_calls: + yield {"id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}]} + yield {"id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}]} + + +gemini_provider = GeminiProvider() diff --git a/services/providers/ninerouter.py b/services/providers/ninerouter.py new file mode 100644 index 00000000..39159065 --- /dev/null +++ b/services/providers/ninerouter.py @@ -0,0 +1,208 @@ +""" +9Router Proxy Provider — forward chat requests to 9router. + +9router handles OAuth for Claude, Codex, Copilot, Gemini CLI, Cursor... +So chatgpt2api just proxies to 9router which has all the tokens. +This is the best approach: 9router for chat, chatgpt2api for image + web UI. +""" + +from __future__ import annotations + +import json +from typing import Any, Iterator + +from curl_cffi import requests + +from services.config import config +from utils.log import logger + + +class NinerouterProxy: + """Proxy chat requests to 9router's OpenAI-compatible endpoint.""" + + def __init__(self): + self._base_url: str = "" + self._api_key: str = "" + + @property + def base_url(self) -> str: + if not self._base_url: + cfg = config.data.get("ninerouter") or {} + self._base_url = str(cfg.get("base_url") or "http://localhost:20128").rstrip("/") + return self._base_url + + @property + def api_key(self) -> str: + if not self._api_key: + cfg = config.data.get("ninerouter") or {} + self._api_key = str(cfg.get("api_key") or "") + return self._api_key + + @property + def chat_url(self) -> str: + return f"{self.base_url}/v1/chat/completions" + + @property + def models_url(self) -> str: + return f"{self.base_url}/v1/models" + + @property + def is_available(self) -> bool: + try: + resp = requests.get(f"{self.base_url}/version", timeout=5) + return resp.status_code < 500 + except Exception: + return False + + def list_models(self) -> list[dict[str, Any]]: + """Fetch available models from 9router.""" + try: + headers = {"Accept": "application/json"} + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + resp = requests.get(self.models_url, headers=headers, timeout=15) + if resp.status_code != 200: + return [] + + data = resp.json() + models = data.get("data") if isinstance(data, dict) else data + if not isinstance(models, list): + return [] + + # Prefix 9router models with 9r/ + return [ + { + "id": f"9r/{m.get('id', '')}", + "object": "model", + "created": m.get("created", 0), + "owned_by": f"9router/{m.get('owned_by', '')}", + } + for m in models + if isinstance(m, dict) + ] + + except Exception as exc: + logger.warning({"event": "9router_models_error", "error": str(exc)}) + return [] + + def chat_completions( + self, + messages: list[dict[str, Any]], + model: str = "auto", + stream: bool = False, + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **kwargs, + ) -> dict[str, Any] | Iterator[str]: + """Forward chat request to 9router. + + 9router handles: + - OAuth token management (Claude, Codex, Copilot, ...) + - Multi-provider routing & fallback + - Format translation (OpenAI ↔ Claude ↔ Gemini) + - No 24KB payload limit + + Args: + messages: Chat messages + model: Model name (WITHOUT 9r/ prefix — we strip it) + stream: Whether to stream + temperature: Sampling temperature + max_tokens: Max tokens + + Returns: + Dict for non-streaming, Iterator[str] for streaming SSE + """ + # Strip 9r/ prefix + if model.startswith("9r/"): + model = model[3:] + if not model or model == "auto": + model = "auto" + + body: dict[str, Any] = { + "model": model, + "messages": messages, + "stream": stream, + } + if temperature is not None: + body["temperature"] = temperature + if max_tokens is not None: + body["max_tokens"] = max_tokens + if tools: + body["tools"] = tools + if tool_choice: + body["tool_choice"] = tool_choice + body.update(kwargs) + + headers = { + "Content-Type": "application/json", + "Accept": "text/event-stream" if stream else "application/json", + } + if self.api_key: + headers["Authorization"] = f"Bearer {self.api_key}" + + logger.info({ + "event": "9router_proxy_request", + "model": model, + "stream": stream, + "message_count": len(messages), + "url": self.chat_url, + }) + + try: + resp = requests.post( + self.chat_url, + headers=headers, + json=body, + timeout=300, + stream=stream, + ) + + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:1000] + except Exception: + pass + logger.error({ + "event": "9router_proxy_error", + "status": resp.status_code, + "error": error_text, + }) + raise RuntimeError( + f"9router error: status={resp.status_code}, body={error_text[:200]}" + ) + + if stream: + return self._stream_response(resp) + else: + return resp.json() + + except requests.RequestsError as exc: + logger.error({"event": "9router_connection_error", "error": str(exc)}) + raise RuntimeError(f"9router connection failed: {exc}") from exc + + def _stream_response(self, response) -> Iterator[str]: + """Pass through SSE stream from 9router.""" + try: + 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) + yield line + "\n" + except Exception as exc: + logger.error({"event": "9router_stream_error", "error": str(exc)}) + error_chunk = json.dumps({ + "error": { + "message": f"9router stream error: {exc}", + "type": "proxy_error", + } + }, ensure_ascii=False) + yield f"data: {error_chunk}\n\n" + yield "data: [DONE]\n\n" + + +# Singleton +ninerouter_proxy = NinerouterProxy() diff --git a/services/providers/nvidia_nim.py b/services/providers/nvidia_nim.py new file mode 100644 index 00000000..1eecf5c0 --- /dev/null +++ b/services/providers/nvidia_nim.py @@ -0,0 +1,301 @@ +""" +NVIDIA NIM Provider — OpenAI-compatible API for chat + vision. + +Base URL: https://integrate.api.nvidia.com/v1 +Auth: Bearer token from https://build.nvidia.com +Format: OpenAI-compatible — forward nguyên bản, không cần convert. + +Supports: Chat, Vision (base64 images), streaming SSE. +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Iterator + +from curl_cffi import requests + +from services.config import config +from utils.log import logger + +NVIDIA_BASE_URL = "https://integrate.api.nvidia.com/v1" + + +class NvidiaNimProvider: + """NVIDIA NIM — OpenAI-compatible proxy for chat + vision.""" + + def __init__(self): + self._key_index = 0 + self._rate_limited: dict[str, float] = {} + + def _get_keys(self) -> list[str]: + cfg = config.data.get("providers") or {} + nv_cfg = cfg.get("nvidia_nim") or {} + single = str(nv_cfg.get("api_key") or "").strip() + multi = nv_cfg.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + @property + def api_key(self) -> str: + keys = self._get_keys() + if not keys: + return "" + # Skip rate-limited keys + now = time.time() + for _ in range(len(keys)): + key = keys[self._key_index % len(keys)] + self._key_index += 1 + if self._rate_limited.get(key, 0) < now: + return key + # All keys rate limited, return least recently limited + return min(keys, key=lambda k: self._rate_limited.get(k, 0)) + + @property + def is_available(self) -> bool: + if not self.api_key: + return False + try: + resp = requests.get( + f"{NVIDIA_BASE_URL}/models", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=10, + ) + return resp.status_code == 200 + except Exception: + return False + + def chat_completions( + self, + messages: list[dict[str, Any]], + model: str = "openai/gpt-oss-120b", + stream: bool = False, + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **kwargs, + ) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Forward chat request to NVIDIA NIM API.""" + if not self.api_key: + raise RuntimeError("NVIDIA NIM API key not configured") + + # Strip nv/ prefix if present + pure_model = model + for prefix in ("nv/",): + if model.startswith(prefix): + pure_model = model[len(prefix):] + break + + body: dict[str, Any] = { + "model": pure_model, + "messages": messages, + "stream": stream, + } + if temperature is not None: + body["temperature"] = temperature + if max_tokens: + body["max_tokens"] = max_tokens + if tools: + body["tools"] = tools + if tool_choice: + body["tool_choice"] = tool_choice + + # Pass through extra params + for key in ("top_p", "frequency_penalty", "presence_penalty", "seed"): + if key in kwargs and kwargs[key] is not None: + body[key] = kwargs[key] + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + if stream: + headers["Accept"] = "text/event-stream" + + logger.info({"event": "nvidia_nim_request", "model": pure_model, "stream": stream}) + + try: + resp = requests.post( + f"{NVIDIA_BASE_URL}/chat/completions", + headers=headers, + json=body, + timeout=300, + stream=stream, + ) + + if resp.status_code == 429: + self._rate_limited[self.api_key] = time.time() + 60 + # Try next key + attempted = getattr(self, "_attempted_keys", set()) + attempted.add(self.api_key) + self._attempted_keys = attempted + if len(attempted) < len(self._get_keys()): + return self.chat_completions( + messages=messages, model=model, stream=stream, + temperature=temperature, max_tokens=max_tokens, + tools=tools, tool_choice=tool_choice, **kwargs, + ) + raise RuntimeError("All NVIDIA NIM API keys rate limited") + + if resp.status_code >= 400: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + logger.error({ + "event": "nvidia_nim_error", + "status": resp.status_code, + "error": error_text, + }) + raise RuntimeError(f"NVIDIA NIM error {resp.status_code}: {error_text[:200]}") + + if stream: + return self._stream_response(resp, model) + else: + return self._non_stream_response(resp, model) + + except requests.RequestsError as exc: + raise RuntimeError(f"NVIDIA NIM connection failed: {exc}") from exc + + def _stream_response(self, response, model: str) -> Iterator[dict[str, Any]]: + """Parse NVIDIA SSE stream → OpenAI chunks.""" + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + accumulated = "" + + try: + 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) + line = line.strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + chunk = json.loads(payload) + except json.JSONDecodeError: + continue + + choices = chunk.get("choices") or [] + for choice in choices: + delta = choice.get("delta") or {} + finish_reason = choice.get("finish_reason") + + # Handle reasoning_content (for deepseek, qwen models) + reasoning = delta.get("reasoning_content") + if reasoning: + accumulated += reasoning + + content = delta.get("content") + if content: + accumulated += content + if not sent_role: + sent_role = True + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant"}, "finish_reason": None}], + } + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"content": content, "reasoning_content": reasoning or None}, "finish_reason": finish_reason}], + } + + # Tool calls + tool_calls_delta = delta.get("tool_calls") + if tool_calls_delta: + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"tool_calls": tool_calls_delta}, "finish_reason": None}], + } + + except Exception as exc: + logger.error({"event": "nvidia_nim_stream_error", "error": str(exc)}) + + if not sent_role: + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], + } + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + + def _non_stream_response(self, response, model: str) -> dict[str, Any]: + """Handle non-streaming NVIDIA response.""" + data = response.json() + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + + choices = data.get("choices") or [] + message = {} + for choice in choices: + msg = choice.get("message") or {} + message = { + "role": msg.get("role", "assistant"), + "content": msg.get("content", ""), + } + if msg.get("tool_calls"): + message["tool_calls"] = msg["tool_calls"] + + usage = data.get("usage") or {} + return { + "id": completion_id, + "object": "chat.completion", + "created": created, + "model": model, + "choices": [{"index": 0, "message": message, "finish_reason": choices[0].get("finish_reason", "stop") if choices else "stop"}], + "usage": { + "prompt_tokens": usage.get("prompt_tokens", 0), + "completion_tokens": usage.get("completion_tokens", 0), + "total_tokens": usage.get("total_tokens", 0), + }, + } + + def list_models(self) -> list[dict[str, Any]]: + """Fetch available models from NVIDIA API, prefixed with nv/.""" + if not self.api_key: + return [] + try: + resp = requests.get( + f"{NVIDIA_BASE_URL}/models", + headers={"Authorization": f"Bearer {self.api_key}"}, + timeout=15, + ) + if resp.status_code != 200: + return [] + models = [] + for item in resp.json().get("data", []): + slug = str(item.get("id") or "").strip() + if slug: + models.append({ + "id": f"nv/{slug}", + "object": "model", + "created": item.get("created", 0), + "owned_by": str(item.get("owned_by") or "nvidia"), + }) + return models + except Exception as exc: + logger.warning({"event": "nvidia_nim_list_models_error", "error": str(exc)}) + return [] + + +# Singleton +nvidia_nim_provider = NvidiaNimProvider() diff --git a/services/providers/openai_oauth.py b/services/providers/openai_oauth.py new file mode 100644 index 00000000..10ddaa01 --- /dev/null +++ b/services/providers/openai_oauth.py @@ -0,0 +1,437 @@ +""" +Codex OAuth Provider — uses 9router Codex tokens to call chatgpt.com/backend-api/codex/responses. + +This is the EXACT same endpoint 9router uses. No api.openai.com — the tokens +work with chatgpt.com's Codex Responses API. No 24KB limit, native tool calling. + +Format: OpenAI Responses API (not chat/completions). +""" + +from __future__ import annotations + +import json +import time +import uuid +from typing import Any, Iterator + +from curl_cffi import requests + +from services.config import config +from services.account_service import account_service +from utils.log import logger + +CODEX_URL = "https://chatgpt.com/backend-api/codex/responses" +CODEX_DEFAULT_MODEL = "gpt-5.3-codex" +CODEX_HEADERS = { + "originator": "codex-cli", + "User-Agent": "codex-cli/1.0.18 (Windows; x64)", + "Content-Type": "application/json", + "Accept": "text/event-stream", +} + + +def _chat_to_responses_input(messages: list[dict[str, Any]], tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, instructions: str | None = None) -> dict[str, Any]: + """Convert OpenAI chat format → Codex Responses API format. + + Handles the full conversation flow including tool calls: + - system → instructions + - user → input_item (role="user") + - assistant (text) → input_item (role="assistant") + - assistant (tool_calls) → function_call items + - tool (result) → function_call_output items + """ + body: dict[str, Any] = {"stream": True} + + input_items: list[dict[str, Any]] = [] + for msg in messages: + role = msg.get("role", "user") + content = msg.get("content", "") + + if isinstance(content, list): + text_parts = [] + image_parts = [] + for part in content: + if isinstance(part, dict): + if part.get("type") == "text": + text_parts.append(str(part.get("text", ""))) + elif part.get("type") == "image_url": + url = part.get("image_url", {}).get("url", "") + if url.startswith("data:"): + header, b64 = url.split(",", 1) + mime = header.split(";")[0].replace("data:", "") + image_parts.append({"type": "input_image", "image_url": url}) + elif url: + image_parts.append({"type": "input_image", "image_url": url}) + elif part.get("type") == "input_image": + image_parts.append(part) + content = " ".join(text_parts) if text_parts else "" + # Build Responses-format content with images + if image_parts: + items = [] + if content: + items.append({"type": "input_text", "text": content}) + for img in image_parts: + img_url = img.get("image_url", "") + if isinstance(img_url, str) and img_url.startswith("data:"): + # Inline base64 image + items.append({"type": "input_image", "image_url": img_url}) + elif isinstance(img_url, str): + items.append({"type": "input_image", "image_url": img_url}) + input_items.append({"role": "user", "content": items}) + continue + else: + content = str(content or "") + + if role == "system": + instructions = (instructions or "") + "\n" + content + continue + + # Tool call result → function_call_output in Responses API + if role == "tool": + tool_call_id = str(msg.get("tool_call_id") or "") + input_items.append({ + "type": "function_call_output", + "call_id": tool_call_id, + "output": content, + }) + continue + + # Assistant message with tool_calls → function_call items + if role == "assistant": + tool_calls = msg.get("tool_calls") + if isinstance(tool_calls, list) and tool_calls: + # First, add any text content the assistant said before calling tools + if content and content.strip(): + input_items.append({"role": "assistant", "content": content}) + # Then add each function_call as an item + for tc in tool_calls: + if isinstance(tc, dict): + fn = tc.get("function") or {} + input_items.append({ + "type": "function_call", + "call_id": str(tc.get("id") or ""), + "name": str(fn.get("name") or ""), + "arguments": str(fn.get("arguments") or ""), + }) + continue + # Regular assistant text response + input_items.append({"role": "assistant", "content": content}) + continue + + # User message + if role == "user": + input_items.append({"role": "user", "content": content}) + else: + input_items.append({"role": "user", "content": content}) + + body["input"] = input_items + + if instructions and instructions.strip(): + body["instructions"] = instructions.strip() + + if tools: + body["tools"] = [{ + "type": "function", + "name": t.get("function", {}).get("name", ""), + "description": t.get("function", {}).get("description", ""), + "parameters": t.get("function", {}).get("parameters", {}), + } for t in tools if isinstance(t, dict)] + + if tool_choice: + body["tool_choice"] = tool_choice + + return body + + +def _responses_to_chat_chunk(event: dict[str, Any], model: str, completion_id: str, created: int) -> dict[str, Any] | None: + """Convert Codex Responses SSE event → OpenAI chat completion chunk.""" + event_type = event.get("type", "") + + if event_type == "response.output_text.delta": + delta = event.get("delta", "") + return { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"content": delta}, "finish_reason": None}], + } + + if event_type == "response.output_item.done": + item = event.get("item", {}) + if item.get("type") == "function_call": + return { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": { + "tool_calls": [{ + "index": 0, + "id": item.get("call_id", ""), + "type": "function", + "function": { + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), + }, + }] + }, "finish_reason": None}], + } + + if event_type == "response.completed": + return { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + + if event_type == "error": + return { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": { + "content": f"Codex error: {event.get('message', 'unknown')}" + }, "finish_reason": "stop"}], + } + + return None + + +class CodexOAuthProvider: + """Direct Codex OAuth — no 9router dependency.""" + + def chat_completions( + self, + access_token: str, + messages: list[dict[str, Any]], + model: str = "auto", + stream: bool = True, + temperature: float | None = None, + max_tokens: int | None = None, + tools: list[dict[str, Any]] | None = None, + tool_choice: Any = None, + **kwargs, + ) -> dict[str, Any] | Iterator[dict[str, Any]]: + """Call Codex Responses API with OAuth token.""" + + instructions = None + body = _chat_to_responses_input(messages, tools, tool_choice, instructions) + + # Codex requires these four to be set + model = model if model and model != "auto" else CODEX_DEFAULT_MODEL + body["model"] = model + body["store"] = False + body["stream"] = True # Codex requires streaming + if "instructions" not in body or not body.get("instructions"): + body["instructions"] = "You are a helpful assistant." + + # Codex rejects these parameters — strip them (like 9router does) + for key in ("temperature", "top_p", "frequency_penalty", "presence_penalty", + "n", "seed", "logprobs", "top_logprobs", "user", + "stream_options", "safety_identifier", "metadata", + "parallel_tool_calls"): + body.pop(key, None) + + # Pass max_tokens as max_output_tokens (Responses API format) + # Only if explicitly provided — don't set a default (Codex may reject it) + # NOTE: Codex Responses API rejects max_output_tokens — don't pass it + # if max_tokens: + # body["max_output_tokens"] = max_tokens + + headers = dict(CODEX_HEADERS) + headers["Authorization"] = f"Bearer {access_token}" + + logger.info({ + "event": "codex_request", + "model": model, + "stream": True, + "message_count": len(messages), + "body_keys": list(body.keys()), + "has_instructions": bool(body.get("instructions")), + "input_count": len(body.get("input", [])), + }) + + try: + resp = requests.post( + CODEX_URL, headers=headers, json=body, + timeout=300, stream=True, + impersonate="chrome110", + ) + + if resp.status_code == 401: + raise RuntimeError("Codex OAuth token expired") + if resp.status_code >= 400: + error_text = "" + try: + # Read raw bytes — stream=True may prevent .text/.content from working + raw = b"" + for chunk in resp.iter_content(chunk_size=8192): + if chunk: + raw += chunk if isinstance(chunk, bytes) else chunk.encode() + if len(raw) > 10000: + break + if raw: + error_text = raw.decode("utf-8", errors="ignore")[:1000] + except Exception: + try: + error_text = (resp.text or "")[:1000] + except Exception: + pass + # Also log response headers for debugging + resp_headers = dict(resp.headers) if hasattr(resp, 'headers') else {} + logger.error({ + "event": "codex_upstream_error", + "status": resp.status_code, + "error": error_text, + "headers": {k: str(v)[:200] for k, v in resp_headers.items()}, + "url": CODEX_URL, + }) + raise RuntimeError(f"Codex error {resp.status_code}: {error_text[:200]}") + + # Codex always streams — collect if caller requested non-streaming + if stream: + return self._stream_response(resp, model or "auto") + else: + # Collect stream into single response + text = "" + tool_calls = [] + for chunk in self._stream_response(resp, model or "auto"): + delta = chunk.get("choices", [{}])[0].get("delta", {}) + text += delta.get("content", "") + if delta.get("tool_calls"): + tool_calls.extend(delta["tool_calls"]) + if delta.get("finish_reason") == "stop": + break + + message = {"role": "assistant", "content": text} + if tool_calls: + message["tool_calls"] = tool_calls + + from services.protocol.openai_v1_chat_complete import count_message_tokens, count_text_tokens + + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": model or "auto", + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": count_message_tokens(messages, model or "auto"), + "completion_tokens": count_text_tokens(text, model or "auto"), + "total_tokens": count_message_tokens(messages, model or "auto") + count_text_tokens(text, model or "auto"), + }, + } + + except requests.RequestsError as exc: + raise RuntimeError(f"Codex connection failed: {exc}") from exc + + def _stream_response(self, response, model: str) -> Iterator[dict[str, Any]]: + """Convert Codex SSE → OpenAI chat completion chunks (dicts).""" + completion_id = f"chatcmpl-{uuid.uuid4().hex}" + created = int(time.time()) + sent_role = False + + try: + 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) + line = line.strip() + if not line.startswith("data: "): + continue + payload = line[6:] + if payload == "[DONE]": + break + try: + event = json.loads(payload) + except json.JSONDecodeError: + continue + + chunk = _responses_to_chat_chunk(event, model, completion_id, created) + if chunk: + if not sent_role and chunk["choices"][0]["delta"].get("content"): + chunk["choices"][0]["delta"]["role"] = "assistant" + sent_role = True + yield chunk + + except Exception as exc: + logger.error({"event": "codex_stream_error", "error": str(exc)}) + + if not sent_role: + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {"role": "assistant", "content": ""}, "finish_reason": None}], + } + yield { + "id": completion_id, "object": "chat.completion.chunk", + "created": created, "model": model, + "choices": [{"index": 0, "delta": {}, "finish_reason": "stop"}], + } + + def _non_stream_response(self, response, model: str, messages: list[dict[str, Any]]) -> dict[str, Any]: + """Handle non-streaming Codex response.""" + data = response.json() + output_text = "" + tool_calls = [] + + for item in data.get("output", []): + if item.get("type") == "message": + for content_item in item.get("content", []): + if content_item.get("type") == "output_text": + output_text += content_item.get("text", "") + elif item.get("type") == "function_call": + tool_calls.append({ + "id": item.get("call_id", ""), + "type": "function", + "function": { + "name": item.get("name", ""), + "arguments": item.get("arguments", ""), + }, + }) + + message = {"role": "assistant", "content": output_text} + if tool_calls: + message["tool_calls"] = tool_calls + + from services.protocol.openai_v1_chat_complete import count_message_tokens, count_text_tokens + + return { + "id": f"chatcmpl-{uuid.uuid4().hex}", + "object": "chat.completion", + "created": int(time.time()), + "model": model, + "choices": [{"index": 0, "message": message, "finish_reason": "stop"}], + "usage": { + "prompt_tokens": count_message_tokens(messages, model), + "completion_tokens": count_text_tokens(output_text, model), + "total_tokens": count_message_tokens(messages, model) + count_text_tokens(output_text, model), + }, + } + + def get_token_for_request(self, exclude_tokens: set[str] | None = None) -> str: + """Get next available JWT token for Codex OAuth. Accepts any JWT token.""" + excluded = set(exclude_tokens or set()) + with account_service._lock: + all_items = list(account_service._accounts.values()) + logger.info({ + "event": "codex_debug", + "total_accounts": len(all_items), + "statuses": [i.get("status") for i in all_items], + "types": [i.get("type") for i in all_items], + "has_jwt": sum(1 for i in all_items if str(i.get("access_token","")).startswith("eyJ")), + }) + candidates = [ + token + for item in all_items + if item.get("status") not in ("disabled", "error") + and (token := item.get("access_token") or "") + and token.startswith("eyJ") + and token not in excluded + ] + if not candidates: + raise RuntimeError("No Codex OAuth tokens available. Add via OAuth login or import 9router backup.") + token = candidates[account_service._index % len(candidates)] + account_service._index += 1 + return token + + +# Singleton +codex_oauth = CodexOAuthProvider() diff --git a/services/providers/opencode.py b/services/providers/opencode.py new file mode 100644 index 00000000..415d054e --- /dev/null +++ b/services/providers/opencode.py @@ -0,0 +1,206 @@ +""" +OpenCode Free Provider — port from 9router open-sse/executors/opencode.js. + +OpenCode provides free LLM access via https://opencode.ai/zen/v1/chat/completions. +No authentication required — uses virtual "public" token. +This bypasses ChatGPT's 24KB free-account payload limit entirely. +""" + +from __future__ import annotations + +import json +import time +from typing import Any, Iterator + +from curl_cffi import requests + +from utils.helper import sse_json_stream +from utils.log import logger + +OPENCODE_BASE_URL = "https://opencode.ai" +OPENCODE_CHAT_URL = f"{OPENCODE_BASE_URL}/zen/v1/chat/completions" +OPENCODE_MODELS_URL = f"{OPENCODE_BASE_URL}/zen/v1/models" +OPENCODE_MESSAGES_URL = f"{OPENCODE_BASE_URL}/zen/v1/messages" + +# Models that use Claude format (messages endpoint) +CLAUDE_FORMAT_MODELS = {"big-pickle"} + +# Default headers — ported from 9router OpenCodeExecutor +DEFAULT_HEADERS = { + "Authorization": "Bearer public", + "x-opencode-client": "desktop", + "Content-Type": "application/json", + "Accept": "text/event-stream, application/json", +} + + +class OpenCodeProvider: + """Free LLM provider via OpenCode.ai — no API key needed.""" + + def __init__(self): + self._models_cache: list[dict[str, Any]] | None = None + self._models_cache_time: float = 0 + self._models_cache_ttl: float = 300 # 5 minutes + + @property + def is_available(self) -> bool: + """Quick availability check.""" + try: + resp = requests.get( + f"{OPENCODE_BASE_URL}/zen/v1/models", + headers=DEFAULT_HEADERS, + timeout=10, + ) + return resp.status_code < 500 + except Exception: + return False + + def list_models(self, force_refresh: bool = False) -> list[dict[str, Any]]: + """Fetch available free models from OpenCode. + + Filters to models ending in '-free' (port from 9router opencode-free filter). + """ + now = time.time() + if ( + not force_refresh + and self._models_cache is not None + and (now - self._models_cache_time) < self._models_cache_ttl + ): + return self._models_cache + + try: + resp = requests.get(OPENCODE_MODELS_URL, headers=DEFAULT_HEADERS, timeout=15) + if resp.status_code != 200: + logger.warning({"event": "opencode_models_fetch_failed", "status": resp.status_code}) + return self._models_cache or [] + + data = resp.json() + all_models = data.get("data") if isinstance(data, dict) else data + if not isinstance(all_models, list): + all_models = [] + + # Filter to free models (port from 9router opencode-free filter) + free_models = [ + { + "id": f"oc/{m.get('id', '')}", + "object": "model", + "created": int(now), + "owned_by": "opencode", + } + for m in all_models + if isinstance(m, dict) and str(m.get("id", "")).endswith("-free") + ] + + self._models_cache = free_models + self._models_cache_time = now + return free_models + + except Exception as exc: + logger.warning({"event": "opencode_models_fetch_error", "error": str(exc)}) + return self._models_cache or [] + + def chat_completions( + self, + messages: list[dict[str, Any]], + model: str = "auto", + stream: bool = False, + temperature: float | None = None, + max_tokens: int | None = None, + **kwargs, + ) -> dict[str, Any] | Iterator[str]: + """Send chat request to OpenCode API. + + Args: + messages: Normalized chat messages + model: Model name (without oc/ prefix) + stream: Whether to stream the response + temperature: Sampling temperature + max_tokens: Max tokens to generate + + Returns: + Dict for non-streaming, Iterator[str] for streaming SSE chunks + """ + body: dict[str, Any] = { + "messages": messages, + "model": model, + "stream": stream, + } + if temperature is not None: + body["temperature"] = temperature + if max_tokens is not None: + body["max_tokens"] = max_tokens + body.update(kwargs) + + # Some models use Claude format (messages endpoint) + if model in CLAUDE_FORMAT_MODELS: + url = OPENCODE_MESSAGES_URL + else: + url = OPENCODE_CHAT_URL + + logger.info({ + "event": "opencode_request", + "model": model, + "stream": stream, + "message_count": len(messages), + }) + + try: + resp = requests.post( + url, + headers=DEFAULT_HEADERS, + json=body, + timeout=300, + stream=stream, + ) + + if resp.status_code != 200: + error_text = "" + try: + error_text = resp.text[:500] + except Exception: + pass + logger.error({ + "event": "opencode_error", + "status": resp.status_code, + "error": error_text, + }) + raise RuntimeError( + f"OpenCode API error: status={resp.status_code}, body={error_text}" + ) + + if stream: + return self._stream_response(resp) + else: + return resp.json() + + except requests.RequestsError as exc: + logger.error({"event": "opencode_connection_error", "error": str(exc)}) + raise RuntimeError(f"OpenCode connection failed: {exc}") from exc + + def _stream_response(self, response) -> Iterator[str]: + """Stream SSE response from OpenCode, yielding OpenAI-compatible chunks.""" + try: + 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 line.startswith("data: "): + payload = line[6:] + if payload.strip() == "[DONE]": + yield "data: [DONE]\n\n" + break + yield f"data: {payload}\n\n" + except Exception as exc: + logger.error({"event": "opencode_stream_error", "error": str(exc)}) + error_chunk = json.dumps({ + "error": { + "message": str(exc), + "type": "opencode_stream_error", + } + }, ensure_ascii=False) + yield f"data: {error_chunk}\n\n" + yield "data: [DONE]\n\n" + + +# Singleton +opencode_provider = OpenCodeProvider() diff --git a/services/quota_watcher.py b/services/quota_watcher.py new file mode 100644 index 00000000..dcdd3390 --- /dev/null +++ b/services/quota_watcher.py @@ -0,0 +1,312 @@ +""" +Quota Watcher — proactive token refresh scheduler. + +Ports the min-heap priority queue pattern from CLIProxyAPI's auto_refresh_loop.go. +Runs as a background asyncio task, checking account quotas periodically and +refreshing tokens BEFORE they hit zero or expire. + +Key differences from reactive-only approach: +- Predicts quota exhaustion and refreshes proactively +- Maintains a min-heap sorted by next check time +- Supports concurrent refresh workers +- Tracks restore_at for automatic re-enablement +""" + +from __future__ import annotations + +import asyncio +import heapq +import time +from dataclasses import dataclass, field +from datetime import datetime, timedelta +from typing import Any + +from services.account_service import account_service +from services.config import config +from utils.log import logger + +# Refresh check interval (seconds) — how often to re-evaluate each account +DEFAULT_CHECK_INTERVAL = 300 # 5 minutes + +# Lead time before quota exhaustion to trigger refresh (seconds) +QUOTA_EXHAUSTION_LEAD = 600 # 10 minutes + +# Max concurrent refresh workers +MAX_REFRESH_WORKERS = 4 + +# Cooldown after a failed refresh attempt (seconds) +REFRESH_FAILURE_COOLDOWN = 300 # 5 minutes + + +@dataclass(order=True) +class QuotaCheckItem: + """Min-heap entry ordered by next_check_at.""" + next_check_at: float + account_id: str = field(compare=False) + provider: str = field(compare=False) + + +class QuotaWatcher: + """Background scheduler that proactively checks and refreshes account quotas. + + Uses a min-heap priority queue (like CLIProxyAPI's authAutoRefreshLoop). + Accounts with approaching quota exhaustion or expiry are checked first. + """ + + def __init__(self): + self._heap: list[QuotaCheckItem] = [] + self._index: dict[str, QuotaCheckItem] = {} # account_id -> item + self._running = False + self._task: asyncio.Task | None = None + self._wake_event = asyncio.Event() + self._refresh_semaphore = asyncio.Semaphore(MAX_REFRESH_WORKERS) + + # ── Public API ────────────────────────────────────────────── + + async def start(self): + """Start the background watcher loop.""" + if self._running: + return + self._running = True + self._rebuild() + logger.info({"event": "quota_watcher_started", "accounts": len(self._heap)}) + self._task = asyncio.create_task(self._loop()) + + async def stop(self): + """Stop the background watcher loop.""" + self._running = False + if self._task: + self._task.cancel() + try: + await self._task + except asyncio.CancelledError: + pass + logger.info({"event": "quota_watcher_stopped"}) + + def schedule_now(self, account_id: str): + """Force immediate recheck of a specific account.""" + self._wake_event.set() + + def _rebuild(self): + """Rebuild the min-heap from all accounts.""" + try: + accounts = account_service.list_accounts() + except Exception as e: + logger.error({"event": "quota_watcher_rebuild_error", "error": str(e)}) + return + now = time.time() + + self._heap.clear() + self._index.clear() + + count = 0 + for account in accounts: + if not isinstance(account, dict): + continue + acc_id = account.get("access_token", "") + if not acc_id: + continue + + status = str(account.get("status", "")) + quota = int(account.get("quota") or 0) + + # Skip disabled/abnormal accounts + if status in ("disabled", "error"): + continue + + # Determine next check time + next_check = self._next_check_time(account, now) + + item = QuotaCheckItem(next_check_at=next_check, account_id=acc_id) + heapq.heappush(self._heap, item) + self._index[acc_id] = item + count += 1 + + logger.info({"event": "quota_watcher_rebuilt", "accounts": count, "total_in_pool": len(accounts)}) + + def _next_check_time(self, account: dict[str, Any], now: float) -> float: + """Calculate when this account should next be checked. + + Priority rules (like CLIProxyAPI's nextRefreshCheckAt): + 1. Low quota (< 5) → check in 1 minute + 2. Rate-limited → check at restore_at time + 3. Normal → check in DEFAULT_CHECK_INTERVAL + 4. Never used → check now + """ + status = account.get("status", "") + quota = int(account.get("quota") or 0) + restore_at = account.get("restore_at") + last_used = account.get("last_used_at") + + # Rate-limited: check right after restore_at + if status == "limited" and restore_at: + try: + restore_ts = _parse_iso_timestamp(restore_at) + if restore_ts and restore_ts > now: + return restore_ts + 30 # 30s after restore + except (ValueError, TypeError): + pass + return now + DEFAULT_CHECK_INTERVAL + + # Low quota: check more frequently + if 0 < quota < 5: + return now + 60 # 1 minute + + # Quota exhausted, waiting for restore + if quota == 0: + return now + 120 # 2 minutes + + # Never used: check soon + if not last_used: + return now + 30 + + # Normal: regular interval + return now + DEFAULT_CHECK_INTERVAL + + # ── Main Loop ────────────────────────────────────────────── + + async def _loop(self): + """Main event loop — process due items, sleep until next.""" + last_rebuild = time.time() + rebuild_interval = 1800 # Full rebuild every 30 minutes + + while self._running: + try: + now = time.time() + + # Periodic full rebuild to catch new/removed accounts + if now - last_rebuild >= rebuild_interval: + self._rebuild() + last_rebuild = now + + # Process all due items + while self._heap and self._heap[0].next_check_at <= now: + item = heapq.heappop(self._heap) + self._index.pop(item.account_id, None) + await self._process_account(item.account_id) + + # Calculate sleep duration + if self._heap: + wait = max(0, self._heap[0].next_check_at - time.time()) + else: + wait = DEFAULT_CHECK_INTERVAL + + # Sleep or wake on signal + try: + await asyncio.wait_for( + self._wake_event.wait(), + timeout=min(wait, 60) + ) + self._wake_event.clear() + except asyncio.TimeoutError: + pass + + except asyncio.CancelledError: + raise + except Exception: + logger.exception({"event": "quota_watcher_loop_error"}) + await asyncio.sleep(60) + + async def _process_account(self, account_id: str): + """Check and potentially refresh a single account.""" + async with self._refresh_semaphore: + try: + accounts = account_service.list_accounts() + account = None + for a in accounts: + if a.get("access_token") == account_id: + account = a + break + + if not account: + return # Account was removed + + # Check if we should refresh + if self._should_refresh(account): + logger.info({ + "event": "quota_watcher_refresh", + "account_id": account_id[:20] + "...", + "quota": account.get("quota"), + "status": account.get("status"), + }) + await asyncio.to_thread( + account_service.refresh_accounts, [account_id] + ) + + # Re-schedule with updated next check time + now = time.time() + next_check = self._next_check_time(account, now) + item = QuotaCheckItem(next_check_at=next_check, account_id=account_id) + heapq.heappush(self._heap, item) + self._index[account_id] = item + + except Exception: + logger.exception({ + "event": "quota_watcher_process_error", + "account_id": account_id[:20] + "...", + }) + # Re-queue with cooldown after failure + next_check = time.time() + REFRESH_FAILURE_COOLDOWN + item = QuotaCheckItem(next_check_at=next_check, account_id=account_id) + heapq.heappush(self._heap, item) + self._index[account_id] = item + + def _should_refresh(self, account: dict[str, Any]) -> bool: + """Determine if an account needs refreshing based on its state. + + Like CLIProxyAPI's shouldRefresh: + - Quota low or expired + - Status is rate-limited (check if restore_at passed) + - Never been refreshed + """ + status = account.get("status", "") + quota = int(account.get("quota") or 0) + restore_at = account.get("restore_at") + + # Rate-limited: check if restore time has passed + if status == "limited": + if restore_at: + try: + restore_ts = _parse_iso_timestamp(restore_at) + if restore_ts and time.time() >= restore_ts: + return True # Should be restored now + except (ValueError, TypeError): + pass + return False # Still in cooldown + + # Low quota: refresh to check latest status + if quota < 5: + return True + + # Abnormal: should refresh to check if recovered + if status == "error": + return True + + return False + + # ── Health API ───────────────────────────────────────────── + + def get_stats(self) -> dict[str, Any]: + """Get watcher statistics for health endpoint.""" + return { + "heap_size": len(self._heap), + "running": self._running, + "workers": MAX_REFRESH_WORKERS, + "check_interval": DEFAULT_CHECK_INTERVAL, + } + + +def _parse_iso_timestamp(value: str) -> float | None: + """Parse ISO timestamp string to Unix timestamp.""" + if not value: + return None + try: + dt = datetime.fromisoformat(str(value).replace("Z", "+00:00")) + return dt.timestamp() + except (ValueError, TypeError): + pass + return None + + +# Singleton +quota_watcher = QuotaWatcher() diff --git a/services/rate_limit_backoff.py b/services/rate_limit_backoff.py new file mode 100644 index 00000000..d2c53047 --- /dev/null +++ b/services/rate_limit_backoff.py @@ -0,0 +1,230 @@ +""" +Rate Limit Backoff — port from 9router open-sse/services/accountFallback.js. + +Exponential backoff with configurable levels: +- base: 2000ms, max: 300000ms (5 phút), maxLevel: 15 +- Error rules: text match trước, status code sau +- Per-model locking: model_lock_{model} +- Transient cooldown: 30s cho lỗi tạm thời + +Used by both chat (via 9router providers) and image (via ChatGPT DALL-E) paths. +""" + +from __future__ import annotations + +import time +from typing import Any + + +# Backoff config — ported from 9router BACKOFF_CONFIG +BACKOFF_BASE_MS = 2000 +BACKOFF_MAX_MS = 300_000 # 5 minutes +BACKOFF_MAX_LEVEL = 15 +TRANSIENT_COOLDOWN_MS = 30_000 # 30 seconds +MAX_RATE_LIMIT_COOLDOWN_MS = 1_800_000 # 30 minutes (for provider-reported resets) + +# Error rules — ported from 9router ERROR_RULES (open-sse/config/errorConfig.js) +# Priority order: text match first, then status code +ERROR_RULES: list[dict[str, Any]] = [ + # Text-based rules (checked case-insensitive) + {"text": "no credentials", "cooldown_ms": 120_000, "level": 2}, + {"text": "request not allowed", "cooldown_ms": 120_000, "level": 2}, + {"text": "improperly formed request", "cooldown_ms": 60_000, "level": 1}, + {"text": "rate limit", "cooldown_ms": None, "level": None}, # Use exponential + {"text": "too many requests", "cooldown_ms": None, "level": None}, # Use exponential + {"text": "quota exceeded", "cooldown_ms": None, "level": None}, # Use exponential + {"text": "capacity", "cooldown_ms": 30_000, "level": 1}, + {"text": "overloaded", "cooldown_ms": 30_000, "level": 1}, + {"text": "token_invalidated", "cooldown_ms": 0, "level": 0}, + {"text": "token_revoked", "cooldown_ms": 0, "level": 0}, + # Status-based rules + {"status": 401, "cooldown_ms": 120_000, "level": 2}, + {"status": 402, "cooldown_ms": 120_000, "level": 2}, + {"status": 403, "cooldown_ms": 120_000, "level": 2}, + {"status": 404, "cooldown_ms": 120_000, "level": 2}, + {"status": 429, "cooldown_ms": None, "level": None}, # Use exponential backoff + {"status": 503, "cooldown_ms": TRANSIENT_COOLDOWN_MS, "level": 1}, +] + + +class RateLimitBackoff: + """Per-account, per-model exponential backoff tracker. + + Ported from 9router accountFallback.js checkFallbackError() + + getQuotaCooldown(). + """ + + def __init__(self): + # {account_key: {model_key: current_level}} + self._levels: dict[str, dict[str, int]] = {} + # {account_key: {model_key: locked_until_timestamp}} + self._locks: dict[str, dict[str, float]] = {} + + def _account_key(self, account_id: str) -> str: + return account_id or "unknown" + + def _model_key(self, model: str) -> str: + return (model or "default").strip() + + def record_failure( + self, + account_id: str, + model: str, + error_text: str = "", + status_code: int = 0, + provider_reported_ms: int | None = None, + ) -> float: + """Record a failure and return the recommended cooldown in seconds. + + Args: + account_id: Account identifier (email or token hash) + model: Model name that failed + error_text: Error message from upstream + status_code: HTTP status code + provider_reported_ms: Provider-reported reset time in ms (e.g., codex resets_at) + + Returns: + Cooldown duration in seconds (0 = no cooldown, can retry immediately) + """ + acc_key = self._account_key(account_id) + mdl_key = self._model_key(model) + + # Initialize tracking if needed + self._levels.setdefault(acc_key, {}) + current_level = self._levels[acc_key].get(mdl_key, 0) + + # If provider reported a precise reset time, use it + if provider_reported_ms and provider_reported_ms > 0: + cooldown_ms = min(provider_reported_ms, MAX_RATE_LIMIT_COOLDOWN_MS) + self._set_lock(acc_key, mdl_key, cooldown_ms / 1000) + return cooldown_ms / 1000 + + # Match error rules — text first, then status + error_lower = str(error_text or "").lower() + + for rule in ERROR_RULES: + # Text match + if "text" in rule and rule["text"] in error_lower: + return self._apply_rule(acc_key, mdl_key, rule, current_level) + + # Status match + if "status" in rule and rule["status"] == status_code: + return self._apply_rule(acc_key, mdl_key, rule, current_level) + + # Default: transient cooldown + cooldown_s = TRANSIENT_COOLDOWN_MS / 1000 + new_level = min(current_level + 1, BACKOFF_MAX_LEVEL) + self._levels[acc_key][mdl_key] = new_level + self._set_lock(acc_key, mdl_key, cooldown_s) + return cooldown_s + + def _apply_rule( + self, + acc_key: str, + mdl_key: str, + rule: dict[str, Any], + current_level: int, + ) -> float: + """Apply a matched error rule and return cooldown in seconds.""" + cooldown_ms = rule.get("cooldown_ms") + rule_level = rule.get("level") + + if cooldown_ms is not None and rule_level is not None: + # Fixed cooldown + self._levels[acc_key][mdl_key] = rule_level + cooldown_s = cooldown_ms / 1000 + self._set_lock(acc_key, mdl_key, cooldown_s) + return cooldown_s + else: + # Exponential backoff + new_level = min(current_level + 1, BACKOFF_MAX_LEVEL) + self._levels[acc_key][mdl_key] = new_level + cooldown_ms = min(BACKOFF_BASE_MS * (2 ** (new_level - 1)), BACKOFF_MAX_MS) + # Add 10% jitter (port from 9router) + import random + jitter = cooldown_ms * 0.1 * (random.random() * 2 - 1) + cooldown_ms = int(cooldown_ms + jitter) + cooldown_s = cooldown_ms / 1000 + self._set_lock(acc_key, mdl_key, cooldown_s) + return cooldown_s + + def record_success(self, account_id: str, model: str) -> None: + """Reduce backoff level on successful request (fast recovery). + + Ported from 9router: decrement by 2 on success, min 0. + """ + acc_key = self._account_key(account_id) + mdl_key = self._model_key(model) + + if acc_key in self._levels and mdl_key in self._levels[acc_key]: + self._levels[acc_key][mdl_key] = max(0, self._levels[acc_key][mdl_key] - 2) + + # Clear lock on success + if acc_key in self._locks and mdl_key in self._locks[acc_key]: + del self._locks[acc_key][mdl_key] + + def is_locked(self, account_id: str, model: str) -> bool: + """Check if an account is locked for a specific model.""" + acc_key = self._account_key(account_id) + mdl_key = self._model_key(model) + + if acc_key not in self._locks or mdl_key not in self._locks[acc_key]: + return False + + locked_until = self._locks[acc_key][mdl_key] + return time.time() < locked_until + + def get_lock_remaining(self, account_id: str, model: str) -> float: + """Get remaining lock time in seconds (0 if not locked).""" + acc_key = self._account_key(account_id) + mdl_key = self._model_key(model) + + if acc_key not in self._locks or mdl_key not in self._locks[acc_key]: + return 0.0 + + remaining = self._locks[acc_key][mdl_key] - time.time() + return max(0.0, remaining) + + def _set_lock(self, acc_key: str, mdl_key: str, cooldown_s: float) -> None: + """Set a per-model lock on an account.""" + self._locks.setdefault(acc_key, {}) + self._locks[acc_key][mdl_key] = time.time() + cooldown_s + + def clear_account(self, account_id: str) -> None: + """Clear all state for an account (e.g., on token removal).""" + acc_key = self._account_key(account_id) + self._levels.pop(acc_key, None) + self._locks.pop(acc_key, None) + + def cleanup_stale(self, max_age_seconds: float = 1800) -> int: + """Remove entries not accessed in max_age_seconds (30 min default). + + Returns: + Number of account entries removed. + """ + # Note: current implementation keeps levels/locks indefinitely. + # This is a placeholder for future stale cleanup. + return 0 + + def get_stats(self) -> dict[str, Any]: + """Get backoff statistics for monitoring.""" + total_locked = 0 + for acc_locks in self._locks.values(): + for locked_until in acc_locks.values(): + if time.time() < locked_until: + total_locked += 1 + + total_levels = 0 + for acc_levels in self._levels.values(): + total_levels += sum(acc_levels.values()) + + return { + "total_accounts_tracked": len(self._levels), + "total_locked_models": total_locked, + "total_backoff_levels": total_levels, + "max_level": BACKOFF_MAX_LEVEL, + } + + +# Singleton +rate_limit_backoff = RateLimitBackoff() diff --git a/services/register_service.py b/services/register_service.py index 3e4d281d..99a16945 100644 --- a/services/register_service.py +++ b/services/register_service.py @@ -116,7 +116,7 @@ def _append_log(self, text: str, color: str = "") -> None: def _pool_metrics(self) -> dict: items = account_service.list_accounts() - normal = [item for item in items if item.get("status") == "正常"] + normal = [item for item in items if item.get("status") == "active"] 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), @@ -128,11 +128,11 @@ def _target_reached(self, cfg: dict, submitted: int) -> bool: 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") + self._append_log(f"检查号池:当前active账号={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") + self._append_log(f"检查号池:当前active账号={metrics['current_available']},目标账号={cfg.get('target_available')},当前剩余额度={metrics['current_quota']},{'跳过注册' if reached else '继续注册'}", "yellow") return reached return submitted >= int(cfg.get("total") or 1) diff --git a/services/search_service.py b/services/search_service.py new file mode 100644 index 00000000..0f1b5cfa --- /dev/null +++ b/services/search_service.py @@ -0,0 +1,669 @@ +""" +Search Service — cấu hình chọn backend search ngay trong chatgpt2api. + +Port pattern from 9router web search providers. +Supports: chatgpt (built-in), gemini (Google Grounding), serper, searxng, brave. + +Flow: +1. auto_detect: analyze user intent → cần search không? +2. If needed → call configured search backend +3. Format results → inject vào messages +4. Send enriched messages to LLM +""" + +from __future__ import annotations + +import json +import re +import time +from typing import Any + +from curl_cffi import requests + +from services.config import config +from utils.log import logger + +# Vietnamese word character class (includes diacritics) +_VI_WORD = r"[a-zA-ZàáảãạâầấẩẫậăằắẳẵặèéẻẽẹêềếểễệìíỉĩịòóỏõọôồốổỗộơờớởỡợùúủũụưừứửữựỳýỷỹỵđĐ]+" + +# Keywords that trigger search intent detection +SEARCH_INTENT_PATTERNS = [ + rf"(?:giá|bao nhiêu|mấy nghìn|mấy triệu)\s+{_VI_WORD}", + rf"(?:hôm nay|hôm qua|tuần này|tháng này|năm nay)\s+{_VI_WORD}", + rf"(?:thời tiết|nhiệt độ|dự báo)\s+{_VI_WORD}", + rf"(?:tin tức|tin mới|báo chí)\s+{_VI_WORD}", + rf"(?:kết quả|tỉ số|trận đấu)\s+{_VI_WORD}", + r"\b(?:search|tìm kiếm|tìm hiểu|tra cứu)\b", + r"\b(?:ai là|ở đâu|khi nào|thế nào|làm sao)\b", + r"\b(?:giá|hỏi\s+giá)\b", +] + + +class SearchBackend: + """Base class for search backends.""" + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + raise NotImplementedError + + +class ChatGPTSearch(SearchBackend): + """Passthrough — let ChatGPT handle search internally (built-in web search tool).""" + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + return [] # ChatGPT handles search internally, no injection needed + + +class SerperSearch(SearchBackend): + """Serper.dev Google Search API (free 2,500 req/month).""" + + BASE_URL = "https://google.serper.dev/search" + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + provider_config = (config.data.get("providers") or {}).get("serper") or {} + api_key = str(provider_config.get("api_key") or "").strip() + + if not api_key: + logger.warning({"event": "serper_no_api_key"}) + return [] + + try: + resp = requests.post( + self.BASE_URL, + headers={ + "X-API-KEY": api_key, + "Content-Type": "application/json", + }, + json={"q": query, "num": max_results}, + timeout=15, + ) + if resp.status_code != 200: + logger.warning({"event": "serper_error", "status": resp.status_code}) + return [] + + data = resp.json() + results: list[dict[str, str]] = [] + for item in (data.get("organic") or [])[:max_results]: + results.append({ + "title": str(item.get("title") or ""), + "snippet": str(item.get("snippet") or ""), + "url": str(item.get("link") or ""), + }) + return results + + except Exception as exc: + logger.warning({"event": "serper_exception", "error": str(exc)}) + return [] + + +class SearXNGSearcher(SearchBackend): + """SearXNG self-hosted search (no API key, no limits).""" + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + provider_config = (config.data.get("providers") or {}).get("searxng") or {} + base_url = str(provider_config.get("base_url") or "http://localhost:8080").strip().rstrip("/") + + try: + resp = requests.get( + f"{base_url}/search", + params={"q": query, "format": "json", "categories": "general"}, + timeout=15, + ) + if resp.status_code != 200: + logger.warning({"event": "searxng_error", "status": resp.status_code}) + return [] + + data = resp.json() + results: list[dict[str, str]] = [] + for item in (data.get("results") or [])[:max_results]: + results.append({ + "title": str(item.get("title") or ""), + "snippet": str(item.get("content") or item.get("snippet") or ""), + "url": str(item.get("url") or ""), + }) + return results + + except Exception as exc: + logger.warning({"event": "searxng_exception", "error": str(exc)}) + return [] + + +class BraveSearch(SearchBackend): + """Brave Search API (free 2,000 req/month).""" + + BASE_URL = "https://api.search.brave.com/res/v1/web/search" + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + provider_config = (config.data.get("providers") or {}).get("brave") or {} + api_key = str(provider_config.get("api_key") or "").strip() + + if not api_key: + logger.warning({"event": "brave_no_api_key"}) + return [] + + try: + resp = requests.get( + self.BASE_URL, + headers={ + "X-Subscription-Token": api_key, + "Accept": "application/json", + }, + params={"q": query, "count": max_results}, + timeout=15, + ) + if resp.status_code != 200: + logger.warning({"event": "brave_error", "status": resp.status_code}) + return [] + + data = resp.json() + results: list[dict[str, str]] = [] + for item in (data.get("web", {}).get("results") or [])[:max_results]: + results.append({ + "title": str(item.get("title") or ""), + "snippet": str(item.get("description") or ""), + "url": str(item.get("url") or ""), + }) + return results + + except Exception as exc: + logger.warning({"event": "brave_exception", "error": str(exc)}) + return [] + + +class GeminiGrounding(SearchBackend): + """Google Search grounding via Gemini API. Uses model from providers.gemini_free.model.""" + + def __init__(self): + self._key_index = 0 + self._rate_limited: dict[str, float] = {} + + def _get_model(self) -> str: + cfg = (config.data.get("providers") or {}).get("gemini_free") or {} + # Use search-specific model if set, otherwise use chat model + return str(cfg.get("search_model") or cfg.get("model") or "gemini-2.5-flash") + + def _get_keys(self) -> list[str]: + provider_config = (config.data.get("providers") or {}).get("gemini_free") or {} + single = str(provider_config.get("api_key") or "").strip() + multi = provider_config.get("api_keys") or [] + if not isinstance(multi, list): + multi = [] + keys = [k.strip() for k in multi if k.strip()] + if single and single not in keys: + keys.insert(0, single) + return keys + + def _next_key(self) -> str | None: + keys = self._get_keys() + if not keys: + return None + import time + now = time.time() + for _ in range(len(keys)): + key = keys[self._key_index % len(keys)] + self._key_index += 1 + locked_until = self._rate_limited.get(key, 0) + if now < locked_until: + continue + return key + return keys[0] # all limited, return first anyway + + def _mark_limited(self, key: str) -> None: + import time + self._rate_limited[key] = time.time() + 60 + # Log only once per key per minute + last_log = getattr(self, '_last_log', {}) + now = time.time() + if key not in last_log or now - last_log[key] > 60: + last_log[key] = now + self._last_log = last_log + logger.warning({"event": "gemini_rate_limited", "key_prefix": key[:10]}) + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + api_key = self._next_key() + if not api_key: + logger.warning({"event": "gemini_no_api_key"}) + return [] + + try: + resp = requests.post( + f"https://generativelanguage.googleapis.com/v1beta/models/{self._get_model()}:generateContent?key={api_key}", + headers={"Content-Type": "application/json", "x-goog-api-key": api_key}, + json={"contents": [{"parts": [{"text": query}]}], "tools": [{"google_search": {}}]}, + timeout=30, + ) + + if resp.status_code in (429, 403): + self._mark_limited(api_key) + now = time.time() + available = [k for k in self._get_keys() if self._rate_limited.get(k, 0) < now] + if available: + return self.search(query, max_results) + logger.warning({"event": "gemini_all_keys_blocked", "status": resp.status_code}) + return [] + + if resp.status_code != 200: + logger.warning({"event": "gemini_error", "status": resp.status_code, "body": resp.text[:200]}) + return [] + + data = resp.json() + results: list[dict[str, str]] = [] + candidates = data.get("candidates") or [] + + # Get the model's search-grounded text for actual data + model_text = "" + for c in candidates: + parts = (c.get("content") or {}).get("parts") or [] + model_text = " ".join(p.get("text", "") for p in parts if isinstance(p, dict)) + + for c in candidates: + grounding = c.get("groundingMetadata") or {} + chunks = grounding.get("groundingChunks") or [] + sources = grounding.get("webSearchQueries") or [] + for chunk in chunks[:max_results]: + web = chunk.get("web") or {} + snippet = str(web.get("snippet") or "") + if not snippet: + snippet = model_text[:300] # Fallback to model response + results.append({ + "title": str(web.get("title") or (sources[0] if sources else query)), + "snippet": snippet, + "url": str(web.get("uri") or ""), + }) + return results[:max_results] + + except Exception as exc: + logger.warning({"event": "gemini_exception", "error": str(exc)}) + return [] + + +class CustomProviderSearch(SearchBackend): + """Use a custom provider's chat model for search (e.g., Gemini API server). + + Sends the query as a chat message with search instruction. Useful for + Gemini-compatible APIs that support Google grounding natively. + """ + + def __init__(self, provider_id: str = ""): + self.provider_id = provider_id + + def search(self, query: str, max_results: int = 3) -> list[dict[str, str]]: + if not self.provider_id: + return [] + + from services.providers.custom_openai import get_custom_providers, CustomOpenAIProvider + providers = get_custom_providers() + cfg = providers.get(self.provider_id) + if not cfg: + return [] + + provider = CustomOpenAIProvider(cfg) + prefix = str(cfg.get("prefix") or self.provider_id) + + # Get first available model from this provider + models = provider.list_models() + if not models: + return [] + + # Pick the first model (usually the best/fastest one) + model_id = str(models[0].get("id") or "").replace(f"{prefix}/", "") + if not model_id: + return [] + + try: + result = provider.chat_completions( + messages=[{ + "role": "user", + "content": ( + f"Tìm kiếm trên Google: {query}\n\n" + f"Hãy trả lời với thông tin thực tế, cập nhật mới nhất. " + f"Trả về tối đa {max_results} kết quả với định dạng:\n" + f"1. [Tiêu đề](URL): mô tả ngắn\n" + f"2. [Tiêu đề](URL): mô tả ngắn" + ), + }], + model=model_id, + stream=False, + max_tokens=1000, + temperature=0.3, + ) + + content = "" + if isinstance(result, dict): + choices = result.get("choices") or [] + if choices: + content = str(choices[0].get("message", {}).get("content") or "") + + if not content: + return [] + + # Parse the response into structured results + results: list[dict[str, str]] = [] + import re + lines = content.strip().split("\n") + for line in lines[:max_results]: + # Parse "1. [Title](URL): description" or "1. Title: description" + match = re.match(r"\d+\.\s*\[?(.+?)\]?(?:\((.+?)\))?:\s*(.*)", line.strip()) + if match: + results.append({ + "title": match.group(1).strip(), + "url": match.group(2) or "", + "snippet": match.group(3).strip(), + }) + elif line.strip() and len(results) < max_results: + # Fallback: use whole line as snippet + text = re.sub(r"^\d+\.\s*", "", line.strip()) + if text and len(text) > 10: + results.append({ + "title": text[:80], + "url": "", + "snippet": text, + }) + + if results: + logger.info({ + "event": "custom_provider_search_ok", + "provider": self.provider_id, + "model": model_id, + "results": len(results), + }) + return results + + except Exception as exc: + logger.warning({ + "event": "custom_provider_search_error", + "provider": self.provider_id, + "error": str(exc), + }) + return [] + + +# Backend registry +SEARCH_BACKENDS: dict[str, SearchBackend] = { + "chatgpt": ChatGPTSearch(), + "gemini": GeminiGrounding(), + "serper": SerperSearch(), + "searxng": SearXNGSearcher(), + "brave": BraveSearch(), +} + + +def get_all_search_backends() -> dict[str, dict[str, str]]: + """Get all available search backends including custom providers.""" + backends = dict(SEARCH_BACKENDS) + # Add custom providers as potential search backends + from services.providers.custom_openai import get_custom_providers + for cp_id, cp_cfg in get_custom_providers().items(): + key = f"custom:{cp_id}" + if key not in backends: + backends[key] = CustomProviderSearch(cp_id) + return {k: {"name": getattr(v, "provider_id", k) if hasattr(v, "provider_id") else k, + "label": _get_backend_label(k)} for k, v in backends.items()} + + +def _get_backend_label(key: str) -> str: + labels = { + "chatgpt": "ChatGPT (có sẵn)", + "gemini": "Gemini Google Search", + "serper": "Serper.dev", + "searxng": "SearXNG (tự cài)", + "brave": "Brave Search", + } + if key.startswith("custom:"): + cp_id = key[len("custom:"):] + from services.providers.custom_openai import get_custom_providers + providers = get_custom_providers() + cfg = providers.get(cp_id) or {} + return f"{cfg.get('name', cp_id)} (Custom API)" + return labels.get(key, key) + +def needs_search(messages: list[dict[str, Any]]) -> bool: + """Analyze last user message to detect search intent. + + Returns True if the user is likely asking for real-time information. + """ + if not messages: + return False + + # Get last user message + last_text = "" + for msg in reversed(messages): + if msg.get("role") == "user": + last_text = str(msg.get("content") or "").strip().lower() + break + + if not last_text: + return False + + # Skip AI task / image analysis prompts (long prompts with JSON templates) + if len(last_text) > 500 and ("{" in last_text and "}" in last_text and "json" in last_text.lower()): + return False + + # Check against search intent patterns + for pattern in SEARCH_INTENT_PATTERNS: + if re.search(pattern, last_text): + return True + + return False + + +def inject_search_results( + messages: list[dict[str, Any]], + results: list[dict[str, str]], + inject_as: str = "user_message", +) -> list[dict[str, Any]]: + """Inject search results into the message list. + + Args: + messages: Current message list + results: Search results [{title, snippet, url}, ...] + inject_as: How to inject — 'user_message' or 'system_message' + + Returns: + Modified message list with search results injected + """ + if not results: + return messages + + # Format search results + lines = ["Dưới đây là kết quả tìm kiếm Google MỚI NHẤT. Hãy trả lời DỰA TRÊN các thông tin này, trích dẫn số liệu cụ thể:"] + for i, r in enumerate(results, 1): + title = r.get("title", "") + snippet = r.get("snippet", "") + lines.append(f"{i}. {title}: {snippet}") + + search_text = "\n".join(lines) + + result = list(messages) + + if inject_as == "system_message": + # Add as system message before the last user message + insert_pos = len(result) + for i in range(len(result) - 1, -1, -1): + if result[i].get("role") == "user": + insert_pos = i + break + result.insert(insert_pos, {"role": "system", "content": search_text}) + else: + # Add to last user message content + for i in range(len(result) - 1, -1, -1): + if result[i].get("role") == "user": + content = result[i].get("content", "") + if isinstance(content, str): + result[i] = dict(result[i]) + result[i]["content"] = f"{search_text}\n\n---\nCâu hỏi của người dùng: {content}" + elif isinstance(content, list): + # HA format: [{"type":"text","text":"..."}] + result[i] = dict(result[i]) + # Prepend search as a separate text part + search_part = {"type": "text", "text": search_text} + result[i]["content"] = [search_part] + [dict(c) for c in content] + break + + return result + + +class SearchService: + """Orchestrates search across configured backend.""" + + def __init__(self): + self._config_cache: dict[str, Any] = {} + + def _get_config(self) -> dict[str, Any]: + search_config = config.data.get("search") + if isinstance(search_config, dict): + return dict(search_config) + return {} + + @property + def backend_name(self) -> str: + cfg = self._get_config() + return str(cfg.get("backend") or "chatgpt").strip() + + @property + def is_enabled(self) -> bool: + cfg = self._get_config() + if isinstance(cfg.get("enabled"), bool): + return cfg["enabled"] + return True # Default enabled + + @property + def auto_detect(self) -> bool: + cfg = self._get_config() + if isinstance(cfg.get("auto_detect"), bool): + return cfg["auto_detect"] + return True + + @property + def max_results(self) -> int: + cfg = self._get_config() + try: + return max(1, min(10, int(cfg.get("max_results") or 3))) + except (TypeError, ValueError): + return 3 + + @property + def inject_as(self) -> str: + cfg = self._get_config() + return str(cfg.get("inject_as") or "user_message").strip() + + @property + def search_combo(self) -> list[str]: + """Ordered list of search backends to try (combo/fallback).""" + cfg = self._get_config() + combo = cfg.get("search_combo") + if isinstance(combo, list) and combo: + valid = set(SEARCH_BACKENDS.keys()) + # Also allow custom provider backends + from services.providers.custom_openai import get_custom_providers + for cp_id in get_custom_providers(): + valid.add(f"custom:{cp_id}") + return [str(b).strip() for b in combo if str(b).strip() in valid] + # Default: single backend from config + backend = self._get_active_backend() + return [backend] if backend in SEARCH_BACKENDS else ["chatgpt"] + + def _get_backend(self, name: str): + """Get a search backend by name, including custom providers.""" + if name in SEARCH_BACKENDS: + return SEARCH_BACKENDS[name] + if name.startswith("custom:"): + cp_id = name[len("custom:"):] + return CustomProviderSearch(cp_id) + return None + + def _get_active_backend(self) -> str: + """Get actual search backend to use, auto-upgrading chatgpt→gemini if key available.""" + if self.backend_name == "chatgpt": + from services.providers.gemini_free import gemini_provider + if gemini_provider.api_key: + return "gemini" + return self.backend_name + + def search(self, query: str) -> list[dict[str, str]]: + """Execute search using configured backends in combo order. Falls back on failure.""" + combo = self.search_combo + + for backend_name in combo: + backend = self._get_backend(backend_name) + if not backend: + continue + + # Skip chatgpt backend — it's passthrough (no injection) + if backend_name == "chatgpt" or backend_name.startswith("custom:"): + # ChatGPT handles search internally via chat model — skip injection + # Custom providers: let's inject their search results + if backend_name == "chatgpt": + logger.info({"event": "search_combo_chatgpt_skip", "reason": "passthrough"}) + return [] + # custom provider — search and inject results + pass + + logger.info({"event": "search_try_backend", "backend": backend_name, "query_len": len(query)}) + try: + results = backend.search(query, self.max_results) + if results: + logger.info({"event": "search_success", "backend": backend_name, "results": len(results)}) + return results + logger.warning({"event": "search_empty", "backend": backend_name}) + except Exception as exc: + logger.warning({"event": "search_backend_error", "backend": backend_name, "error": str(exc)}) + + logger.warning({"event": "search_all_backends_failed", "tried": combo}) + return [] + + def process_messages(self, messages: list[dict[str, Any]]) -> list[dict[str, Any]]: + """Process messages: detect if search needed, execute, inject results. + + If backend is 'chatgpt', this is a no-op — ChatGPT handles search internally. + """ + if not self.is_enabled: + return messages + + if self.backend_name == "chatgpt": + # Auto-detect Gemini if key configured, otherwise skip + from services.providers.gemini_free import gemini_provider + if not gemini_provider.api_key: + return messages # No search backend available + # else: fall through to use Gemini search + + if self.auto_detect and not needs_search(messages): + return messages + + # Extract query from last user message + query = "" + for msg in reversed(messages): + if msg.get("role") == "user": + content = msg.get("content", "") + # Handle list content format [{"type":"text","text":"..."}] + if isinstance(content, list): + for part in content: + if isinstance(part, dict) and part.get("type") == "text": + query = str(part.get("text") or "").strip() + break + elif isinstance(content, str): + query = content.strip() + break + + if not query: + return messages + + logger.info({ + "event": "search_executing", + "backend": self.backend_name, + "query": query[:200], + }) + + results = self.search(query) + if results: + logger.info({ + "event": "search_results", + "backend": self.backend_name, + "count": len(results), + }) + return inject_search_results(messages, results, self.inject_as) + + return messages + + +# Singleton +search_service = SearchService() diff --git a/services/state_backup.py b/services/state_backup.py new file mode 100644 index 00000000..c6e9d3a4 --- /dev/null +++ b/services/state_backup.py @@ -0,0 +1,339 @@ +""" +State Backup — full system state export/import. + +Port pattern from 9router src/lib/db/index.js exportDb()/importDb(): +- export_all(): đọc TOÀN BỘ state → 1 JSON object +- import_all(payload): validate → xóa cũ → insert mới → trong transaction + +Backup includes: +- ChatGPT accounts (email, token, plan_type, status, health_score) +- Provider configs (opencode, gemini, openrouter, sdwebui, etc.) +- Auth keys (hashed) +- Image tasks metadata +- Full config +- Combo models +- Search config +""" + +from __future__ import annotations + +import gzip +import json +import time +from dataclasses import dataclass, field +from datetime import datetime, timezone +from pathlib import Path +from typing import Any + +from services.config import DATA_DIR, config +from services.account_service import account_service +from utils.helper import anonymize_token +from utils.log import logger + +BACKUP_DIR = DATA_DIR / "backups" +BACKUP_RETENTION_DEFAULT = 5 +CURRENT_SCHEMA_VERSION = 2 + + +@dataclass +class RestoreReport: + """Report after a restore operation.""" + success: bool = True + sections_restored: list[str] = field(default_factory=list) + sections_skipped: list[str] = field(default_factory=list) + errors: list[str] = field(default_factory=list) + backup_version: int = 0 + items_restored: dict[str, int] = field(default_factory=dict) + + def to_dict(self) -> dict[str, Any]: + return { + "success": self.success, + "sections_restored": self.sections_restored, + "sections_skipped": self.sections_skipped, + "errors": self.errors, + "backup_version": self.backup_version, + "items_restored": self.items_restored, + } + + +class StateBackup: + """Full state backup/restore — ported from 9router exportDb/importDb.""" + + def __init__(self): + BACKUP_DIR.mkdir(parents=True, exist_ok=True) + + def export_all(self) -> dict[str, Any]: + """Collect ALL system state into a single JSON-serializable dict. + + Returns: + Complete state snapshot with schema version and metadata. + """ + now = datetime.now(timezone.utc).isoformat() + + state: dict[str, Any] = { + "version": CURRENT_SCHEMA_VERSION, + "created_at": now, + "source": "chatgpt2api", + "data": { + "accounts": self._export_accounts(), + "auth_keys": self._export_auth_keys(), + "config": self._export_config(), + "image_tasks": self._export_image_tasks(), + "combo_models": self._export_combo_models(), + }, + } + + return state + + def _export_accounts(self) -> list[dict[str, Any]]: + """Export all ChatGPT accounts with sensitive fields anonymized.""" + try: + accounts = account_service.list_accounts() + except Exception as exc: + logger.warning({"event": "backup_export_accounts_error", "error": str(exc)}) + return [] + + result = [] + for acc in accounts: + item = dict(acc) + # Keep token but mark it + if "access_token" in item: + item["access_token_hash"] = anonymize_token(item["access_token"]) + result.append(item) + return result + + def _export_auth_keys(self) -> list[dict[str, Any]]: + """Export API keys (hashed).""" + try: + from services.auth_service import auth_service + keys = auth_service.list_keys() + return [dict(k) for k in keys] + except Exception: + return [] + + def _export_config(self) -> dict[str, Any]: + """Export full configuration.""" + try: + return config.get() + except Exception: + return {} + + def _export_image_tasks(self) -> list[dict[str, Any]]: + """Export image task metadata (not the images themselves).""" + try: + tasks_file = DATA_DIR / "image_tasks.json" + if tasks_file.exists(): + data = json.loads(tasks_file.read_text(encoding="utf-8")) + return data if isinstance(data, list) else [] + except Exception: + pass + return [] + + def _export_combo_models(self) -> dict[str, list[str]]: + """Export combo model definitions.""" + combos = config.data.get("combo_models") + return dict(combos) if isinstance(combos, dict) else {} + + def import_all(self, payload: dict[str, Any]) -> RestoreReport: + """Restore full system state from a backup payload. + + Args: + payload: Backup JSON as returned by export_all() + + Returns: + RestoreReport with details of what was restored. + """ + report = RestoreReport() + + # Validate + if not isinstance(payload, dict): + report.success = False + report.errors.append("Backup payload must be a JSON object") + return report + + backup_version = int(payload.get("version") or 0) + report.backup_version = backup_version + + if backup_version > CURRENT_SCHEMA_VERSION: + report.success = False + report.errors.append( + f"Backup version {backup_version} is newer than current version {CURRENT_SCHEMA_VERSION}. " + f"Please upgrade chatgpt2api first." + ) + return report + + data = payload.get("data") + if not isinstance(data, dict): + report.success = False + report.errors.append("Backup payload missing 'data' section") + return report + + # Restore each section + # Order matters: config first, then accounts, then auth keys + + # 1. Config + try: + if isinstance(data.get("config"), dict): + config_data = dict(data["config"]) + # Don't overwrite auth-key from backup + config_data.pop("auth-key", None) + config.update(config_data) + report.sections_restored.append("config") + report.items_restored["config"] = 1 + except Exception as exc: + report.sections_skipped.append(f"config: {exc}") + + # 2. Accounts + try: + accounts = data.get("accounts") + if isinstance(accounts, list) and accounts: + tokens = [ + str(acc.get("access_token") or "").strip() + for acc in accounts + if str(acc.get("access_token") or "").strip() + ] + if tokens: + result = account_service.add_accounts(tokens) + report.sections_restored.append("accounts") + report.items_restored["accounts"] = result.get("added", 0) + else: + report.sections_skipped.append("accounts: no valid tokens found") + except Exception as exc: + report.sections_skipped.append(f"accounts: {exc}") + + # 3. Auth keys + try: + auth_keys = data.get("auth_keys") + if isinstance(auth_keys, list) and auth_keys: + from services.auth_service import auth_service + restored = 0 + for key_data in auth_keys: + if isinstance(key_data, dict): + try: + key_name = str(key_data.get("name") or "restored_key") + permissions = key_data.get("permissions") or ["chat", "image"] + auth_service.create_key(key_name, permissions) + restored += 1 + except Exception: + pass + if restored > 0: + report.sections_restored.append("auth_keys") + report.items_restored["auth_keys"] = restored + except Exception as exc: + report.sections_skipped.append(f"auth_keys: {exc}") + + # 4. Combo models + try: + combos = data.get("combo_models") + if isinstance(combos, dict) and combos: + existing = config.data.get("combo_models") or {} + merged = {**existing, **combos} + config.data["combo_models"] = merged + config._save() + report.sections_restored.append("combo_models") + report.items_restored["combo_models"] = len(combos) + except Exception as exc: + report.sections_skipped.append(f"combo_models: {exc}") + + # 5. Image tasks + try: + image_tasks = data.get("image_tasks") + if isinstance(image_tasks, list) and image_tasks: + tasks_file = DATA_DIR / "image_tasks.json" + tasks_file.write_text( + json.dumps(image_tasks, ensure_ascii=False, indent=2), + encoding="utf-8", + ) + report.sections_restored.append("image_tasks") + report.items_restored["image_tasks"] = len(image_tasks) + except Exception as exc: + report.sections_skipped.append(f"image_tasks: {exc}") + + logger.info({ + "event": "backup_restored", + "backup_version": backup_version, + "sections_restored": report.sections_restored, + "sections_skipped": report.sections_skipped, + }) + + return report + + def save_to_file(self, state: dict[str, Any]) -> Path: + """Save backup state to a local JSON file (gzipped). + + Returns: + Path to the saved backup file. + """ + timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") + filename = f"chatgpt2api_backup_{timestamp}.json.gz" + filepath = BACKUP_DIR / filename + + json_bytes = json.dumps(state, ensure_ascii=False, indent=2, default=str).encode("utf-8") + with gzip.open(filepath, "wb") as f: + f.write(json_bytes) + + logger.info({ + "event": "backup_saved", + "path": str(filepath), + "size_bytes": len(json_bytes), + }) + + self._cleanup_old_backups() + return filepath + + def load_from_file(self, filepath: Path) -> dict[str, Any]: + """Load backup state from a local JSON file (gzipped or plain).""" + path = Path(filepath) + if not path.exists(): + raise FileNotFoundError(f"Backup file not found: {path}") + + if path.suffix == ".gz": + with gzip.open(path, "rb") as f: + return json.loads(f.read().decode("utf-8")) + else: + return json.loads(path.read_text(encoding="utf-8")) + + def list_backups(self) -> list[dict[str, Any]]: + """List available local backup files.""" + backups = [] + if BACKUP_DIR.exists(): + for f in sorted(BACKUP_DIR.glob("chatgpt2api_backup_*.json*"), reverse=True): + stat = f.stat() + backups.append({ + "filename": f.name, + "path": str(f), + "size_bytes": stat.st_size, + "created_at": datetime.fromtimestamp(stat.st_mtime, tz=timezone.utc).isoformat(), + }) + return backups + + def _cleanup_old_backups(self) -> int: + """Remove old backups, keeping only the most recent N.""" + cfg = config.data.get("backup") or {} + retention = int(cfg.get("local_retention") or BACKUP_RETENTION_DEFAULT) + + backups = self.list_backups() + if len(backups) <= retention: + return 0 + + removed = 0 + for backup in backups[retention:]: + try: + Path(backup["path"]).unlink() + removed += 1 + except OSError: + pass + + return removed + + def delete_backup(self, filename: str) -> bool: + """Delete a specific backup file by filename.""" + filepath = BACKUP_DIR / filename + if filepath.exists(): + filepath.unlink() + return True + return False + + +# Singleton +state_backup = StateBackup() diff --git a/test_out.txt b/test_out.txt new file mode 100644 index 00000000..97091eec --- /dev/null +++ b/test_out.txt @@ -0,0 +1,3 @@ +Dưới đây là giá dầu thế giới theo các loại chính (spot/benchmark) gần đây — các số liệu này thể hiện giá dầu thô trên thị trường quốc tế tính theo USD mỗi thùng (USD/barrel):2search0turn2search10 + + Giá dầu thô chủ chốt \ No newline at end of file diff --git a/test_strip.py b/test_strip.py new file mode 100644 index 00000000..359c5d8c --- /dev/null +++ b/test_strip.py @@ -0,0 +1,35 @@ +import re + +CITATION_RE = re.compile(r'【?[0-9†]*\s*citeturn[^\s】]*\s*】?', re.IGNORECASE) + +def test_filtered(): + chunks = [ + "Dưới đây là ", + "giá dầu thế giới theo các loại chính (spot/benchmark) gần đây — các số liệu này thể hiện giá dầu ", + "thô trên thị trường quốc tế tính theo USD mỗi thùng ", + "(USD/barrel): ", + "citeturn", + "2search0", + "turn2search10\n\n", + " Giá dầu thô chủ chốt" + ] + + buffer = "" + out = "" + for delta in chunks: + buffer += delta + buffer = CITATION_RE.sub("", buffer) + buffer = re.sub(r'[^\s]*citeturn[^\s]*', '', buffer, flags=re.IGNORECASE) + if len(buffer) > 50: + out += buffer[:-30] + buffer = buffer[-30:] + + if buffer: + buffer = CITATION_RE.sub("", buffer) + buffer = re.sub(r'[^\s]*citeturn[^\s]*', '', buffer, flags=re.IGNORECASE) + out += buffer + + with open("test_out.txt", "w", encoding="utf-8") as f: + f.write(out) + +test_filtered() diff --git a/test_strip_full.py b/test_strip_full.py new file mode 100644 index 00000000..657fb8d0 --- /dev/null +++ b/test_strip_full.py @@ -0,0 +1,24 @@ +import sys +from services.protocol.conversation import extract_and_remove_tool_calls + +text = """ **Giá xăng dầu **ở Việt Nam hôm nay (06/05/2026) gần như *giữ nguyên* theo mức niêm yết mới nhất và chưa có điều chỉnh trong ngày: citeturn0search29 + +Giá tham khảo tại hệ thống Petrolimex (đơn vị: VNĐ/lít): +citeturn0search29 + +- Xăng RON 95‑III: ~ 23.750 đ/lít (Vùng 1) — ~24.220 đ/lít (Vùng 2) +- Diesel: ~ 28.170 đ – 29.430 đ/lít tùy loại +- Dầu hỏa: ~ 31.980 đ – 32.610 đ/lít citeturn0search29 + + Đây là giá bán lẻ hiện được niêm yết theo kỳ điều hành gần nhất (29/4/2026) và *đã bao gồm thuế VAT + thuế môi trường*. citeturn0search29 +""" + +cleaned, tools = extract_and_remove_tool_calls(text) + +if "citeturn0search29" in cleaned: + print("FAILED! IT LEAKED!") +else: + print("SUCCESS! IT WAS STRIPPED!") + +with open("test_strip_full_out.txt", "w", encoding="utf-8") as f: + f.write(cleaned) diff --git a/test_strip_full_out.txt b/test_strip_full_out.txt new file mode 100644 index 00000000..f7ebe214 --- /dev/null +++ b/test_strip_full_out.txt @@ -0,0 +1,3 @@ +**Giá xăng dầu **ở Việt Nam hôm nay (06/05/2026) gần như *giữ nguyên* theo mức niêm yết mới nhất và chưa có điều chỉnh trong ngày:Giá tham khảo tại hệ thống Petrolimex (đơn vị: VNĐ/lít):- Xăng RON 95‑III: ~ 23.750 đ/lít (Vùng 1) — ~24.220 đ/lít (Vùng 2) +- Diesel: ~ 28.170 đ – 29.430 đ/lít tùy loại +- Dầu hỏa: ~ 31.980 đ – 32.610 đ/lítĐây là giá bán lẻ hiện được niêm yết theo kỳ điều hành gần nhất (29/4/2026) và *đã bao gồm thuế VAT + thuế môi trường*. \ No newline at end of file diff --git a/utils/helper.py b/utils/helper.py index f5546568..4b1bd318 100644 --- a/utils/helper.py +++ b/utils/helper.py @@ -11,9 +11,142 @@ from fastapi import HTTPException from utils.log import logger -IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"} +IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2", "gemini-image/imagen-3.0-generate-001", "gemini-image/gemini-2.5-flash-image", "gemini-image/gemini-3.1-flash-image-preview"} OUTPUT_DIR = Path(__file__).resolve().parent / "output" +# Model capability classification +_IMAGE_GEN_PREFIXES = { + "nv-image/", "sdwebui/", "comfyui/", "huggingface/", + "bfl/", "stability/", "fal-ai/", "cloudflare/", + "recraft/", "runwayml/", +} +_IMAGE_GEN_KEYWORDS = { + "flux", "stable-diffusion", "sdxl", "dall-e", "gpt-image", + "image-generation", "image_generation", "imagen", +} +# Providers where ALL models support image generation +_IMAGE_GEN_PROVIDER_PREFIXES = { + "nv-image/", # NVIDIA image gen (FLUX, SD) + "sdwebui/", # Stable Diffusion WebUI + "comfyui/", # ComfyUI + "huggingface/", # HuggingFace inference + "bfl/", # Black Forest Labs + "stability/", # Stability AI + "fal-ai/", # Fal.ai + "cloudflare/", # Cloudflare AI + "recraft/", # Recraft + "runwayml/", # RunwayML +} +# Providers where ALL models support vision (multimodal) +_VISION_PROVIDER_PREFIXES: set[str] = { + "cx/", # Codex supports image input natively + "gemini_free/", # Gemini API supports image input +} +# Custom providers that support image generation +_IMAGE_GEN_CUSTOM_PROVIDERS = { + "geminiapi", # Gemini API server — supports image gen via /v1/responses +} +# Custom providers where ALL models support vision +_VISION_CUSTOM_PROVIDERS: set[str] = { + "geminiapi", # Gemini-FastAPI supports image input +} +# Individual model keywords for vision (used for nv/ and other providers) +_VISION_KEYWORDS = { + "gemma-2", "gemma-3", "gemma-4", "gemma-7b", + "fuyu", "kosmos", "nvclip", "vila", "trellis", + "phi-3-vision", "phi-4-multimodal", "phi-3.5-moe", + "llama-3.2-11b", "llama-3.2-90b", "llama-4-maverick", + "nemotron-nano-12b-v2-vl", "nemotron-3-nano-omni", + "bevformer", "sparsedrive", "streampetr", "visual-changenet", + "cosmos-predict1", "nv-dinov2", "nv-grounding-dino", + "retail-object-detection", "codegemma", + "yi-large", "sarvam", +} +# Providers where ALL models support video analysis +_VIDEO_PROVIDER_PREFIXES: set[str] = { + "cx/", # Codex supports video via multimodal + "gemini_free/", # Gemini API supports video input +} +# Custom providers where ALL models support video +_VIDEO_CUSTOM_PROVIDERS: set[str] = { + "geminiapi", # Gemini-FastAPI supports video +} + + +def classify_model_capability(model_id: str) -> list[str]: + """Classify a model by capabilities: ['chat'], ['chat','vision'], ['image'], etc. + + Image Gen: models that generate images (FLUX, SD, DALL-E) + Vision: models that can analyze/understand images (multimodal) + Chat: text models (all models are at least chat-capable) + """ + mid = str(model_id or "").strip().lower() + caps: list[str] = [] + + # Check image gen first + is_image = False + for prefix in _IMAGE_GEN_PREFIXES: + if mid.startswith(prefix): + caps.append("image") + is_image = True + break + if not is_image: + for prefix in _IMAGE_GEN_PROVIDER_PREFIXES: + if mid.startswith(prefix): + caps.append("image") + is_image = True + break + if not is_image: + for kw in _IMAGE_GEN_KEYWORDS: + if kw in mid: + caps.append("image") + break + if not is_image: + for cp_prefix in _IMAGE_GEN_CUSTOM_PROVIDERS: + if mid.startswith(f"{cp_prefix}/"): + caps.append("image") + break + + # Check vision capability + for prefix in _VISION_PROVIDER_PREFIXES: + if mid.startswith(prefix): + caps.append("vision") + break + else: + for cp_prefix in _VISION_CUSTOM_PROVIDERS: + if mid.startswith(f"{cp_prefix}/"): + caps.append("vision") + break + else: + for kw in _VISION_KEYWORDS: + if kw in mid: + caps.append("vision") + break + + # All models support chat (unless they're pure image gen with no text) + if not is_image: + caps.append("chat") + + # Check video analysis capability + for prefix in _VIDEO_PROVIDER_PREFIXES: + if mid.startswith(prefix): + if "video" not in caps: + caps.append("video") + break + else: + for cp_prefix in _VIDEO_CUSTOM_PROVIDERS: + if mid.startswith(f"{cp_prefix}/"): + if "video" not in caps: + caps.append("video") + break + + return caps if caps else ["chat"] + + +def get_model_capability_label(cap: str) -> str: + """Human-readable label for model capability.""" + return {"chat": "Chat", "vision": "Phân tích ảnh", "image": "Tạo ảnh", "video": "Phân tích video"}.get(cap, cap) + def new_uuid() -> str: return str(uuid.uuid4()) diff --git a/web/package-lock.json b/web/package-lock.json new file mode 100644 index 00000000..1fa01628 --- /dev/null +++ b/web/package-lock.json @@ -0,0 +1,8956 @@ +{ + "name": "next-starter", + "version": "0.1.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "next-starter", + "version": "0.1.0", + "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.13" + }, + "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" + } + }, + "node_modules/@alloc/quick-lru": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@alloc/quick-lru/-/quick-lru-5.2.0.tgz", + "integrity": "sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/@babel/code-frame": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", + "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.28.5", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.3.tgz", + "integrity": "sha512-LIVqM46zQWZhj17qA8wb4nW/ixr2y1Nw+r1etiAWgRM6U1IqP+LNhL1yg440jYZR72jCWcWbLWzIosH+uP1fqg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", + "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.1", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", + "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", + "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", + "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", + "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", + "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.28.6", + "@babel/helper-validator-identifier": "^7.28.5", + "@babel/traverse": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", + "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.28.5", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", + "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.27.1", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", + "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.2", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.2.tgz", + "integrity": "sha512-HoGuUs4sCZNezVEKdVcwqmZN8GoHirLUcLaYVNBK2J0DadGtdcqgr3BCbvH8+XUo4NGjNl3VOtSjEKNzqfFgKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.28.6", + "@babel/types": "^7.29.0" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.3", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.3.tgz", + "integrity": "sha512-b3ctpQwp+PROvU/cttc4OYl4MzfJUWy6FZg+PMXfzmt/+39iHVF0sDfqay8TQM3JA2EUOyKcFZt75jWriQijsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.0" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/template": { + "version": "7.28.6", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", + "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.28.6", + "@babel/parser": "^7.28.6", + "@babel/types": "^7.28.6" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", + "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.0", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", + "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.27.1", + "@babel/helper-validator-identifier": "^7.28.5" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@date-fns/tz": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/@date-fns/tz/-/tz-1.4.1.tgz", + "integrity": "sha512-P5LUNhtbj6YfI3iJjw5EL9eUAG6OitD0W3fWQcpQjDRc/QIsL0tRNuO1PcDvPccWL1fSTXXdE1ds+l95DV/OFA==", + "license": "MIT" + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.21.2", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.2.tgz", + "integrity": "sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^2.1.7", + "debug": "^4.3.1", + "minimatch": "^3.1.5" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.4.2", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.4.2.tgz", + "integrity": "sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/core": { + "version": "0.17.0", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.17.0.tgz", + "integrity": "sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/eslintrc": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.5.tgz", + "integrity": "sha512-4IlJx0X0qftVsN5E+/vGujTRIFtwuLbNsVUe7TO6zYPDR1O6nFwvwhIKEKSrl6dZchmYBITazxKoUYOjdtjlRg==", + "dev": true, + "license": "MIT", + "dependencies": { + "ajv": "^6.14.0", + "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.1", + "minimatch": "^3.1.5", + "strip-json-comments": "^3.1.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint/js": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.4.tgz", + "integrity": "sha512-nE7DEIchvtiFTwBw4Lfbu59PG+kCofhjsKaCWzxTpt4lfRjRMqG6uMBzKXuEcyXhOHoUp9riAm7/aWYGhXZ9cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + } + }, + "node_modules/@eslint/object-schema": { + "version": "2.1.7", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-2.1.7.tgz", + "integrity": "sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.4.1.tgz", + "integrity": "sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^0.17.0", + "levn": "^0.4.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + } + }, + "node_modules/@exodus/schemasafe": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", + "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@img/colour": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz", + "integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==", + "license": "MIT", + "optional": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/@img/sharp-darwin-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.5.tgz", + "integrity": "sha512-imtQ3WMJXbMY4fxb/Ndp6HBTNVtWCUI0WdobyheGf5+ad6xX8VIDO8u2xE4qc/fr08CKG/7dDseFtn6M6g/r3w==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-darwin-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.5.tgz", + "integrity": "sha512-YNEFAF/4KQ/PeW0N+r+aVVsoIY0/qxxikF2SWdp+NRkmMB7y9LBZAVqQ4yhGCm/H3H270OSykqmQMKLBhBJDEw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-darwin-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-libvips-darwin-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.2.4.tgz", + "integrity": "sha512-zqjjo7RatFfFoP0MkQ51jfuFZBnVE2pRiaydKJ1G/rHZvnsrHAOcQALIi9sA5co5xenQdTugCvtb1cuf78Vf4g==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-darwin-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.2.4.tgz", + "integrity": "sha512-1IOd5xfVhlGwX+zXv2N93k0yMONvUlANylbJw1eTah8K/Jtpi15KC+WSiaX/nBmbm2HxRM1gZ0nSdjSsrZbGKg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "darwin" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.2.4.tgz", + "integrity": "sha512-bFI7xcKFELdiNCVov8e44Ia4u2byA+l3XtsAj+Q8tfCwO6BQ8iDojYdvoPMqsKDkuoOo+X6HZA0s0q11ANMQ8A==", + "cpu": [ + "arm" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.2.4.tgz", + "integrity": "sha512-excjX8DfsIcJ10x1Kzr4RcWe1edC9PquDRRPx3YVCvQv+U5p7Yin2s32ftzikXojb1PIFc/9Mt28/y+iRklkrw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-ppc64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.2.4.tgz", + "integrity": "sha512-FMuvGijLDYG6lW+b/UvyilUWu5Ayu+3r2d1S8notiGCIyYU/76eig1UfMmkZ7vwgOrzKzlQbFSuQfgm7GYUPpA==", + "cpu": [ + "ppc64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-riscv64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.2.4.tgz", + "integrity": "sha512-oVDbcR4zUC0ce82teubSm+x6ETixtKZBh/qbREIOcI3cULzDyb18Sr/Wcyx7NRQeQzOiHTNbZFF1UwPS2scyGA==", + "cpu": [ + "riscv64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-s390x": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.2.4.tgz", + "integrity": "sha512-qmp9VrzgPgMoGZyPvrQHqk02uyjA0/QrTO26Tqk6l4ZV0MPWIW6LTkqOIov+J1yEu7MbFQaDpwdwJKhbJvuRxQ==", + "cpu": [ + "s390x" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linux-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.2.4.tgz", + "integrity": "sha512-tJxiiLsmHc9Ax1bz3oaOYBURTXGIRDODBqhveVHonrHJ9/+k89qbLl0bcJns+e4t4rvaNBxaEZsFtSfAdquPrw==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-arm64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.2.4.tgz", + "integrity": "sha512-FVQHuwx1IIuNow9QAbYUzJ+En8KcVm9Lk5+uGUQJHaZmMECZmOlix9HnH7n1TRkXMS0pGxIJokIVB9SuqZGGXw==", + "cpu": [ + "arm64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-libvips-linuxmusl-x64": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.2.4.tgz", + "integrity": "sha512-+LpyBk7L44ZIXwz/VYfglaX/okxezESc6UxDSoyo2Ks6Jxc4Y7sGjpgU9s4PMgqgjj1gZCylTieNamqA1MF7Dg==", + "cpu": [ + "x64" + ], + "license": "LGPL-3.0-or-later", + "optional": true, + "os": [ + "linux" + ], + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-linux-arm": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.5.tgz", + "integrity": "sha512-9dLqsvwtg1uuXBGZKsxem9595+ujv0sJ6Vi8wcTANSFpwV/GONat5eCkzQo/1O6zRIkh0m/8+5BjrRr7jDUSZw==", + "cpu": [ + "arm" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.5.tgz", + "integrity": "sha512-bKQzaJRY/bkPOXyKx5EVup7qkaojECG6NLYswgktOZjaXecSAeCWiZwwiFf3/Y+O1HrauiE3FVsGxFg8c24rZg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-ppc64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.34.5.tgz", + "integrity": "sha512-7zznwNaqW6YtsfrGGDA6BRkISKAAE1Jo0QdpNYXNMHu2+0dTrPflTLNkpc8l7MUP5M16ZJcUvysVWWrMefZquA==", + "cpu": [ + "ppc64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-ppc64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-riscv64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.34.5.tgz", + "integrity": "sha512-51gJuLPTKa7piYPaVs8GmByo7/U7/7TZOq+cnXJIHZKavIRHAP77e3N2HEl3dgiqdD/w0yUfiJnII77PuDDFdw==", + "cpu": [ + "riscv64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-riscv64": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-s390x": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.5.tgz", + "integrity": "sha512-nQtCk0PdKfho3eC5MrbQoigJ2gd1CgddUMkabUj+rBevs8tZ2cULOx46E7oyX+04WGfABgIwmMC0VqieTiR4jg==", + "cpu": [ + "s390x" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-s390x": "1.2.4" + } + }, + "node_modules/@img/sharp-linux-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.5.tgz", + "integrity": "sha512-MEzd8HPKxVxVenwAa+JRPwEC7QFjoPWuS5NZnBt6B3pu7EG2Ge0id1oLHZpPJdn3OQK+BQDiw9zStiHBTJQQQQ==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linux-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.5.tgz", + "integrity": "sha512-fprJR6GtRsMt6Kyfq44IsChVZeGN97gTD331weR1ex1c1rypDEABN6Tm2xa1wE6lYb5DdEnk03NZPqA7Id21yg==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-arm64": "1.2.4" + } + }, + "node_modules/@img/sharp-linuxmusl-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.5.tgz", + "integrity": "sha512-Jg8wNT1MUzIvhBFxViqrEhWDGzqymo3sV7z7ZsaWbZNDLXRJZoRGrjulp60YYtV4wfY8VIKcWidjojlLcWrd8Q==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "optionalDependencies": { + "@img/sharp-libvips-linuxmusl-x64": "1.2.4" + } + }, + "node_modules/@img/sharp-wasm32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.5.tgz", + "integrity": "sha512-OdWTEiVkY2PHwqkbBI8frFxQQFekHaSSkUIJkwzclWZe64O1X4UlUjqqqLaPbUpMOQk6FBu/HtlGXNblIs0huw==", + "cpu": [ + "wasm32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT", + "optional": true, + "dependencies": { + "@emnapi/runtime": "^1.7.0" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-arm64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.5.tgz", + "integrity": "sha512-WQ3AgWCWYSb2yt+IG8mnC6Jdk9Whs7O0gxphblsLvdhSpSTtmu69ZG1Gkb6NuvxsNACwiPV6cNSZNzt0KPsw7g==", + "cpu": [ + "arm64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-ia32": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.5.tgz", + "integrity": "sha512-FV9m/7NmeCmSHDD5j4+4pNI8Cp3aW+JvLoXcTUo0IqyjSfAZJ8dIUmijx1qaJsIiU+Hosw6xM5KijAWRJCSgNg==", + "cpu": [ + "ia32" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@img/sharp-win32-x64": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.5.tgz", + "integrity": "sha512-+29YMsqY2/9eFEiW93eqWnuLcWcufowXewwSNIT6UwZdUUCrM3oFjMWH/Z6/TMmb4hlFenmfAVbpWeup2jryCw==", + "cpu": [ + "x64" + ], + "license": "Apache-2.0 AND LGPL-3.0-or-later", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "0.2.12", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-0.2.12.tgz", + "integrity": "sha512-ZVWUcfwY4E/yPitQJl481FjFo3K22D6qF0DuFH6Y/nbnE11GY5uguDxZMGXPQ8WQ0128MXQD7TnfHyK4oWoIJQ==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.4.3", + "@emnapi/runtime": "^1.4.3", + "@tybys/wasm-util": "^0.10.0" + } + }, + "node_modules/@next/env": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/env/-/env-16.2.3.tgz", + "integrity": "sha512-ZWXyj4uNu4GCWQw9cjRxWlbD+33mcDszIo9iQxFnBX3Wmgq9ulaSJcl6VhuWx5pCWqqD+9W6Wfz7N0lM5lYPMA==", + "license": "MIT" + }, + "node_modules/@next/eslint-plugin-next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/eslint-plugin-next/-/eslint-plugin-next-16.2.3.tgz", + "integrity": "sha512-nE/b9mht28XJxjTwKs/yk7w4XTaU3t40UHVAky6cjiijdP/SEy3hGsnQMPxmXPTpC7W4/97okm6fngKnvCqVaA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-glob": "3.3.1" + } + }, + "node_modules/@next/swc-darwin-arm64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.2.3.tgz", + "integrity": "sha512-u37KDKTKQ+OQLvY+z7SNXixwo4Q2/IAJFDzU1fYe66IbCE51aDSAzkNDkWmLN0yjTUh4BKBd+hb69jYn6qqqSg==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-darwin-x64": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.2.3.tgz", + "integrity": "sha512-gHjL/qy6Q6CG3176FWbAKyKh9IfntKZTB3RY/YOJdDFpHGsUDXVH38U4mMNpHVGXmeYW4wj22dMp1lTfmu/bTQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.2.3.tgz", + "integrity": "sha512-U6vtblPtU/P14Y/b/n9ZY0GOxbbIhTFuaFR7F4/uMBidCi2nSdaOFhA0Go81L61Zd6527+yvuX44T4ksnf8T+Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-arm64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.2.3.tgz", + "integrity": "sha512-/YV0LgjHUmfhQpn9bVoGc4x4nan64pkhWR5wyEV8yCOfwwrH630KpvRg86olQHTwHIn1z59uh6JwKvHq1h4QEw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-gnu": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.2.3.tgz", + "integrity": "sha512-/HiWEcp+WMZ7VajuiMEFGZ6cg0+aYZPqCJD3YJEfpVWQsKYSjXQG06vJP6F1rdA03COD9Fef4aODs3YxKx+RDQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-linux-x64-musl": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.2.3.tgz", + "integrity": "sha512-Kt44hGJfZSefebhk/7nIdivoDr3Ugp5+oNz9VvF3GUtfxutucUIHfIO0ZYO8QlOPDQloUVQn4NVC/9JvHRk9hw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-arm64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.2.3.tgz", + "integrity": "sha512-O2NZ9ie3Tq6xj5Z5CSwBT3+aWAMW2PIZ4egUi9MaWLkwaehgtB7YZjPm+UpcNpKOme0IQuqDcor7BsW6QBiQBw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@next/swc-win32-x64-msvc": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.2.3.tgz", + "integrity": "sha512-Ibm29/GgB/ab5n7XKqlStkm54qqZE8v2FnijUPBgrd67FWrac45o/RsNlaOWjme/B5UqeWt/8KM4aWBwA1D2Kw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@nodelib/fs.scandir": { + "version": "2.1.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.scandir/-/fs.scandir-2.1.5.tgz", + "integrity": "sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.stat": "2.0.5", + "run-parallel": "^1.1.9" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.stat": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/@nodelib/fs.stat/-/fs.stat-2.0.5.tgz", + "integrity": "sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nodelib/fs.walk": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@nodelib/fs.walk/-/fs.walk-1.2.8.tgz", + "integrity": "sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@nodelib/fs.scandir": "2.1.5", + "fastq": "^1.6.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/@nolyfill/is-core-module": { + "version": "1.0.39", + "resolved": "https://registry.npmjs.org/@nolyfill/is-core-module/-/is-core-module-1.0.39.tgz", + "integrity": "sha512-nn5ozdjYQpUCZlWGuxcJY/KpxkWQs4DcbMCmKojjyrYDEAGy4Ce19NN4v5MduafTwJlbKc99UA8YhSVqq9yPZA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.4.0" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.1.tgz", + "integrity": "sha512-MkKCwxlXTgz6CFoJx3pCwn07GKp36+aZyu/u2Ln2VrA5DcdyCZkASEDBTd8x5whTQQL5CiYf4prXKLcgQdv29g==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.3.tgz", + "integrity": "sha512-JTF99U/6XIjCBo0wqkU5sK10glYe27MRRsfwoiq5zzOEZLHU3A3KCMa5X/azekYRCJ0HlwI0crAXS/5dEHTzDg==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.7.tgz", + "integrity": "sha512-F+M1tLhO+mlQaOWspE8Wstg+z6PwxwRd8oQ8IXceWz92kfAmalTRf0EjrouQeo7QssEPfCn05B4Ihs1K9WQ/7w==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-checkbox": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-checkbox/-/react-checkbox-1.3.3.tgz", + "integrity": "sha512-wBbpv+NQftHDdG86Qc0pIyXk5IR3tM8Vd0nWLKDcX8nNn4nXFOFwsKuqw2okA/1D/mpaAkmuyndrPJTYDNZtFw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.7.tgz", + "integrity": "sha512-Fh9rGN0MoI4ZFUNyfFVNU4y9LUz93u9/0K+yLgA2bwRojxM8JU1DyvvMBabnZPBgMWREAJvU2jjVzq+LrFUglw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.2.tgz", + "integrity": "sha512-z4eqJvfiNnFMHIIvXP3CY57y2WJs5g2v3X0zm9mEJkrkNv4rDxu+sg9Jh8EkXyeqBkB7SOcboo9dMVqhyrACIg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.2.tgz", + "integrity": "sha512-jCi/QKUM2r1Ju5a3J64TH2A5SpKAgh0LpknyqdQ4m6DCV0xJ2HG1xARRwNGPQfi1SLdLWZ1OJz6F4OMBBNiGJA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.15.tgz", + "integrity": "sha512-TCglVRtzlffRNxRMEyR36DGBLJpeusFcgMVD9PZEzAKnUs1lKCgX5u9BmC2Yg+LL9MgZDugFFs1Vl+Jp4t/PGw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.1.tgz", + "integrity": "sha512-1UEWRX6jnOA2y4H5WczZ44gOOjTEmlqv1uNW4GAJEO5+bauCBhv8snY65Iw5/VOS/ghKN9gr2KjnLKxrsvoMVw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.11.tgz", + "integrity": "sha512-Nqcp+t5cTB8BinFkZgXiMJniQH0PsUt2k51FUhbdfeKvc4ACcG2uQniY/8+h1Yv6Kza4Q7lD7PQV0z0oicE0Mg==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.3.tgz", + "integrity": "sha512-0rFg/Rj2Q62NCm62jZw0QX7a3sz6QCQU0LpZdNrJX8byRGaGVTqbrW9jAoIAHyMQqsNpeZ81YgSizOt5WXq0Pw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.7", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.7.tgz", + "integrity": "sha512-t2ODlkXBQyn7jkl6TNaw/MtVEVvIGelJDCG41Okq/KwUsJBwQ4XVZsHAVUkK4mBv3ewiAS3PGuUWuY2BoK4ZUw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.1.tgz", + "integrity": "sha512-kGkGegYIdQsOb4XjsfM97rXsiHaBwco+hFI66oO4s9LU+PLAC5oJ7khdOVFxkhsmlbpUqDAvXw11CluXP+jkHg==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popover/-/react-popover-1.1.15.tgz", + "integrity": "sha512-kr0X2+6Yy/vJzLYJUPCZEc8SfQcf+1COFoAqauJm74umQhta9M7lNJHP7QQS3vkvcGLQUbWpMzwrXYwrYztHKA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popover/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.2.8.tgz", + "integrity": "sha512-0NJQ4LFFUuWkE7Oxf0htBKS6zLkkjBH+hM1uk7Ng705ReR8m/uelduy1DBo0PyBXPKVnBA6YBlU94MBGXrSBCw==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.9.tgz", + "integrity": "sha512-bpIxvq03if6UNwXZ+HTK71JLh4APvnXntDc6XOX8UVq4XQOVl7lwok0AvIl+b8zgCw3fSaVTZMpAPPagXbKmHQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.5.tgz", + "integrity": "sha512-/jfEwNDdQVBCNvjkGit4h6pMOzq8bHkopq458dPt2lMjx+eBQUohZNG9A7DtO/O5ukSbxuaNGXMjHicgwy6rQQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.3.tgz", + "integrity": "sha512-m9gTwRkhy2lvCPe6QJp4d3G1TYEUHn/FzJUtq9MjH46an1wJU+GdoGC5VLof8RX8Ft/DlpshApkhswDLZzHIcQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.2.6.tgz", + "integrity": "sha512-I30RydO+bnn2PQztvo25tswPH+wFBjehVGtmagkU78yMdwTwVf12wnAOF+AeP8S2N8xD+5UPbGhkUfPyvT+mwQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select/node_modules/@radix-ui/react-slot": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.3.tgz", + "integrity": "sha512-aeNmHnBxbi2St0au6VBVC7JXFlhLlOnvIIlePNniyUNAClzmtAUEY8/pBiK3iHjufOlwA+c20/8jngo7xcrg8A==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.2.4.tgz", + "integrity": "sha512-Jl+bCv8HxKnlTLVrcDE8zTMJ09R9/ukw4qBs/oZClOfoQk/cOTbDn+NceXfV7j09YPVQUryJPHurafcSg6EVKA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.1.tgz", + "integrity": "sha512-FkBMwD+qbGQeMu1cOHnuGB6x4yzPjho8ap5WtbEJ26umhgqVXbhekKUQO+hZEL1vU92a3wHwdp0HAcqAUF5iDg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.2.tgz", + "integrity": "sha512-BjasUjixPFdS+NKkypcyyN5Pmg83Olst0+c6vGov0diwTEo6mgdqVR6hxcEgFuh4QrAs7Rc+9KuGJ9TVCj0Zzg==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.2.tgz", + "integrity": "sha512-Qp8WbZOBe+blgpuUT+lw2xheLP8q0oatc9UpmiemEICxGvFLYmHm9QowVZGHtJlGbS6A6yJ3iViad/2cVjnOiA==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.1.tgz", + "integrity": "sha512-Il0+boE7w/XebUHyBjroE+DbByORGR9KKmITzbR7MyQ4akpORYP/ZmbhAr0DG7RmmBqoOnZdy2QlvajJ2QA59g==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.1.tgz", + "integrity": "sha512-RbJRS4UWQFkzHTTwVymMTUv8EqYhOp8dOOviLj2ugtTiXRaRQS7GLGxZTLL1jWhMeoSCf5zmcZkqTl9IiYfXcQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.1.tgz", + "integrity": "sha512-2dHfToCj/pzca2Ck724OZ5L0EVrr3eHRNsG/b3xQJLA2hZpVCS99bLAX+hm1IHXDEnzU6by5z/5MIY794/a8NQ==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.1.tgz", + "integrity": "sha512-QTYuDesS0VtuHNNvMh+CjlKJ4LJickCMUAqjlE3+j8w+RlRpwyX3apEQKGFzbZGdo7XNG1tXa+bQqIE7HIXT2w==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.1" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.1.tgz", + "integrity": "sha512-ewrXRDTAqAXlkl6t/fkXWNAhFX9I+CkKlw6zjEwk86RSPKwZr3xpBRso655aqYafwtnbpHLj6toFzmd6xdVptQ==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.3.tgz", + "integrity": "sha512-pzJq12tEaaIhqjbzpCuv/OypJY/BPavOofm+dbab+MHLajy277+1lLm6JFcGgF5eskJ6mquGirhXY2GD/8u8Ug==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.1.tgz", + "integrity": "sha512-HPwpGIzkl28mWyZqG52jiqDJ12waP11Pa1lGoiyUkIEuMLBP0oeK/C89esbXrxsky5we7dfd8U58nm0SgAWpVw==", + "license": "MIT" + }, + "node_modules/@rtsao/scc": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@rtsao/scc/-/scc-1.1.0.tgz", + "integrity": "sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==", + "dev": true, + "license": "MIT" + }, + "node_modules/@swc/helpers": { + "version": "0.5.15", + "resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.15.tgz", + "integrity": "sha512-JQ5TuMi45Owi4/BIMAJBoSQoOJu12oOk/gADqlcUL9JEdHB8vyjUSsxqeNXnmXHjYKMi2WcYtezGEEhqUI/E2g==", + "license": "Apache-2.0", + "dependencies": { + "tslib": "^2.8.0" + } + }, + "node_modules/@tabby_ai/hijri-converter": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/@tabby_ai/hijri-converter/-/hijri-converter-1.0.5.tgz", + "integrity": "sha512-r5bClKrcIusDoo049dSL8CawnHR6mRdDwhlQuIgZRNty68q0x8k3Lf1BtPAMxRf/GgnHBnIO4ujd3+GQdLWzxQ==", + "license": "MIT", + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.0.tgz", + "integrity": "sha512-aFb4gUhFOgdh9AXo4IzBEOzBkkAxm9VigwDJnMIYv3lcfXCJVesNfbEaBl4BNgVRyid92AmdviqwBUBRKSeY3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "^5.21.0", + "jiti": "^2.6.1", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.0.tgz", + "integrity": "sha512-F7HZGBeN9I0/AuuJS5PwcD8xayx5ri5GhjYUDBEVYUkexyA/giwbDNjRVrxSezE3T250OU2K/wp/ltWx3UOefg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-arm64": "4.3.0", + "@tailwindcss/oxide-darwin-x64": "4.3.0", + "@tailwindcss/oxide-freebsd-x64": "4.3.0", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.0", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.0", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.0", + "@tailwindcss/oxide-linux-x64-musl": "4.3.0", + "@tailwindcss/oxide-wasm32-wasi": "4.3.0", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.0", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.0" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.0.tgz", + "integrity": "sha512-TJPiq67tKlLuObP6RkwvVGDoxCMBVtDgKkLfa/uyj7/FyxvQwHS+UOnVrXXgbEsfUaMgiVvC4KbJnRr26ho4Ng==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.0.tgz", + "integrity": "sha512-oMN/WZRb+SO37BmUElEgeEWuU8E/HXRkiODxJxLe1UTHVXLrdVSgfaJV7pSlhRGMSOiXLuxTIjfsF3wYvz8cgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.0.tgz", + "integrity": "sha512-N6CUmu4a6bKVADfw77p+iw6Yd9Q3OBhe0veaDX+QazfuVYlQsHfDgxBrsjQ/IW+zywL8mTrNd0SdJT/zgtvMdA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.0.tgz", + "integrity": "sha512-zDL5hBkQdH5C6MpqbK3gQAgP80tsMwSI26vjOzjJtNCMUo0lFgOItzHKBIupOZNQxt3ouPH7RPhvNhiTfCe5CQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.0.tgz", + "integrity": "sha512-R06HdNi7A7OEoMsf6d4tjZ71RCWnZQPHj2mnotSFURjNLdBC+cIgXQ7l81CqeoiQftjf6OOblxXMInMgN2VzMA==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.0.tgz", + "integrity": "sha512-qTJHELX8jetjhRQHCLilkVLmybpzNQAtaI/gaoVoidn/ufbNDbAo8KlK2J+yPoc8wQxvDxCmh/5lr8nC1+lTbg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.0.tgz", + "integrity": "sha512-Z6sukiQsngnWO+l39X4pPbiWT81IC+PLKF+PHxIlyZbGNb9MODfYlXEVlFvej5BOZInWX01kVyzeLvHsXhfczQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.0.tgz", + "integrity": "sha512-DRNdQRpSGzRGfARVuVkxvM8Q12nh19l4BF/G7zGA1oe+9wcC6saFBHTISrpIcKzhiXtSrlSrluCfvMuledoCTQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.0.tgz", + "integrity": "sha512-Z0IADbDo8bh6I7h2IQMx601AdXBLfFpEdUotft86evd/8ZPflZe9COPO8Q1vw+pfLWIUo9zN/JGZvwuAJqduqg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.0.tgz", + "integrity": "sha512-HNZGOUxEmElksYR7S6sC5jTeNGpobAsy9u7Gu0AskJ8/20FR9GqebUyB+HBcU/ax6BHuiuJi+Oda4B+YX6H1yA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.1", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.0.tgz", + "integrity": "sha512-Pe+RPVTi1T+qymuuRpcdvwSVZjnll/f7n8gBxMMh3xLTctMDKqpdfGimbMyioqtLhUYZxdJ9wGNhV7MKHvgZsQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.0.tgz", + "integrity": "sha512-Mvrf2kXW/yeW/OTezZlCGOirXRcUuLIBx/5Y12BaPM7wJoryG6dfS/NJL8aBPqtTEx/Vm4T4vKzFUcKDT+TKUA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/postcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/@tailwindcss/postcss/-/postcss-4.3.0.tgz", + "integrity": "sha512-Jm05Tjx+9yCLGv5qw1c+84Psds8MnyrEQYCB+FFk2lgGiUjlRqdxke4mVTuYrj2xnVZqKim2Apr5ySuQRYAw/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@alloc/quick-lru": "^5.2.0", + "@tailwindcss/node": "4.3.0", + "@tailwindcss/oxide": "4.3.0", + "postcss": "^8.5.10", + "tailwindcss": "4.3.0" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json5": { + "version": "0.0.29", + "resolved": "https://registry.npmjs.org/@types/json5/-/json5-0.0.29.tgz", + "integrity": "sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "20.19.40", + "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.40.tgz", + "integrity": "sha512-xxx6M2IpSTnnKcR0cMvIiohkiCx20/oRPtWGbenFygKCGl3zqUzdNjQ/1V4solq1LU+dgv0nQzeGOuqkqZGg0Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~6.21.0" + } + }, + "node_modules/@types/react": { + "version": "19.1.12", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.1.12.tgz", + "integrity": "sha512-cMoR+FoAf/Jyq6+Df2/Z41jISvGZZ2eTlnsaJRptmZ76Caldwy1odD4xTr/gNV9VLj0AWgg/nmkevIyUfIIq5w==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.0.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.1.9", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.1.9.tgz", + "integrity": "sha512-qXRuZaOsAdXKFyOhRBg6Lqqc0yay13vN7KrIg4L7N4aaHN68ma9OK3NE1BoDFgFOTfM7zg+3/8+2n8rLUH3OKQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.0.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.59.2.tgz", + "integrity": "sha512-j/bwmkBvHUtPNxzuWe5z6BEk3q54YRyGlBXkSsmfoih7zNrBvl5A9A98anlp/7JbyZcWIJ8KXo/3Tq/DjFLtuQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/type-utils": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.59.2", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.59.2.tgz", + "integrity": "sha512-plR3pp6D+SSUn1HM7xvSkx12/DhoHInI2YF35KAcVFNZvlC0gtrWqx7Qq1oH2Ssgi0vlFRCTbP+DZc7B9+TtsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.59.2.tgz", + "integrity": "sha512-+2hqvEkeyf/0FBor67duF0Ll7Ot8jyKzDQOSrxazF/danillRq2DwR9dLptsXpoZQqxE1UisSmoZewrlPas9Vw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.59.2", + "@typescript-eslint/types": "^8.59.2", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.59.2.tgz", + "integrity": "sha512-JzfyEpEtOU89CcFSwyNS3mu4MLvLSXqnmX05+aKBDM+TdR5jzcGOEBwxwGNxrEQ7p/z6kK2WyioCGBf2zZBnvg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.59.2.tgz", + "integrity": "sha512-BKK4alN7oi4C/zv4VqHQ+uRU+lTa6JGIZ7s1juw7b3RHo9OfKB+bKX3u0iVZetdsUCBBkSbdWbarJbmN0fTeSw==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.59.2.tgz", + "integrity": "sha512-nhqaj1nmTdVVl/BP5omXNRGO38jn5iosis2vbdmupF2txCf8ylWT8lx+JlvMYYVqzGVKtjojUFoQ3JRWK+mfzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.59.2.tgz", + "integrity": "sha512-e82GVOE8Ps3E++Egvb6Y3Dw0S10u8NkQ9KXmtRhCWJJ8kDhOJTvtMAWnFL16kB1583goCWXsr0NieKCZMs2/0Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.59.2.tgz", + "integrity": "sha512-o0XPGNwcWw+FIwStOWn+BwBuEmL6QXP0rsvAFg7ET1dey1Nr6Wb1ac8p5HEsK0ygO/6mUxlk+YWQD9xcb/nnXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.59.2", + "@typescript-eslint/tsconfig-utils": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/visitor-keys": "8.59.2", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.59.2.tgz", + "integrity": "sha512-Juw3EinkXqjaffxz6roowvV7GZT/kET5vSKKZT6upl5TXdWkLkYmNPXwDDL2Vkt2DPn0nODIS4egC/0AGxKo/Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.59.2", + "@typescript-eslint/types": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.59.2.tgz", + "integrity": "sha512-NwjLUnGy8/Zfx23fl50tRC8rYaYnM52xNRYFAXvmiil9yh1+K6aRVQMnzW6gQB/1DLgWt977lYQn7C+wtgXZiA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.59.2", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/visitor-keys/node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@umijs/openapi": { + "version": "1.14.1", + "resolved": "https://registry.npmjs.org/@umijs/openapi/-/openapi-1.14.1.tgz", + "integrity": "sha512-riFsBjdg6OLNKWCxSyZ4BFmVKd1KkkGebxUCoDiKfz1p6L5CBiw8yVZd8J22Q/fwxKdYQ75GOCkjNouL3bGoYg==", + "dev": true, + "license": "MIT", + "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" + } + }, + "node_modules/@umijs/openapi/node_modules/prettier": { + "version": "2.8.8", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-2.8.8.tgz", + "integrity": "sha512-tdN8qQGvNjw4CHbY+XXk0JgCXn9QiF21a55rBe5LJAU+kDyC4WQn4+awm2Xfk2lQMk5fKup9XgzTZtGkjBdP9Q==", + "dev": true, + "license": "MIT", + "bin": { + "prettier": "bin-prettier.js" + }, + "engines": { + "node": ">=10.13.0" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/@unrs/resolver-binding-android-arm-eabi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm-eabi/-/resolver-binding-android-arm-eabi-1.11.1.tgz", + "integrity": "sha512-ppLRUgHVaGRWUx0R0Ut06Mjo9gBaBkg3v/8AxusGLhsIotbBLuRk51rAzqLC8gq6NyyAojEXglNjzf6R948DNw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-android-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-android-arm64/-/resolver-binding-android-arm64-1.11.1.tgz", + "integrity": "sha512-lCxkVtb4wp1v+EoN+HjIG9cIIzPkX5OtM03pQYkG+U5O/wL53LC4QbIeazgiKqluGeVEeBlZahHalCaBvU1a2g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-arm64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-arm64/-/resolver-binding-darwin-arm64-1.11.1.tgz", + "integrity": "sha512-gPVA1UjRu1Y/IsB/dQEsp2V1pm44Of6+LWvbLc9SDk1c2KhhDRDBUkQCYVWe6f26uJb3fOK8saWMgtX8IrMk3g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-darwin-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-darwin-x64/-/resolver-binding-darwin-x64-1.11.1.tgz", + "integrity": "sha512-cFzP7rWKd3lZaCsDze07QX1SC24lO8mPty9vdP+YVa3MGdVgPmFc59317b2ioXtgCMKGiCLxJ4HQs62oz6GfRQ==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ] + }, + "node_modules/@unrs/resolver-binding-freebsd-x64": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-freebsd-x64/-/resolver-binding-freebsd-x64-1.11.1.tgz", + "integrity": "sha512-fqtGgak3zX4DCB6PFpsH5+Kmt/8CIi4Bry4rb1ho6Av2QHTREM+47y282Uqiu3ZRF5IQioJQ5qWRV6jduA+iGw==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-gnueabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-gnueabihf/-/resolver-binding-linux-arm-gnueabihf-1.11.1.tgz", + "integrity": "sha512-u92mvlcYtp9MRKmP+ZvMmtPN34+/3lMHlyMj7wXJDeXxuM0Vgzz0+PPJNsro1m3IZPYChIkn944wW8TYgGKFHw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm-musleabihf": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm-musleabihf/-/resolver-binding-linux-arm-musleabihf-1.11.1.tgz", + "integrity": "sha512-cINaoY2z7LVCrfHkIcmvj7osTOtm6VVT16b5oQdS4beibX2SYBwgYLmqhBjA1t51CarSaBuX5YNsWLjsqfW5Cw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-gnu/-/resolver-binding-linux-arm64-gnu-1.11.1.tgz", + "integrity": "sha512-34gw7PjDGB9JgePJEmhEqBhWvCiiWCuXsL9hYphDF7crW7UgI05gyBAi6MF58uGcMOiOqSJ2ybEeCvHcq0BCmQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-arm64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-arm64-musl/-/resolver-binding-linux-arm64-musl-1.11.1.tgz", + "integrity": "sha512-RyMIx6Uf53hhOtJDIamSbTskA99sPHS96wxVE/bJtePJJtpdKGXO1wY90oRdXuYOGOTuqjT8ACccMc4K6QmT3w==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-ppc64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-ppc64-gnu/-/resolver-binding-linux-ppc64-gnu-1.11.1.tgz", + "integrity": "sha512-D8Vae74A4/a+mZH0FbOkFJL9DSK2R6TFPC9M+jCWYia/q2einCubX10pecpDiTmkJVUH+y8K3BZClycD8nCShA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-gnu/-/resolver-binding-linux-riscv64-gnu-1.11.1.tgz", + "integrity": "sha512-frxL4OrzOWVVsOc96+V3aqTIQl1O2TjgExV4EKgRY09AJ9leZpEg8Ak9phadbuX0BA4k8U5qtvMSQQGGmaJqcQ==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-riscv64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-riscv64-musl/-/resolver-binding-linux-riscv64-musl-1.11.1.tgz", + "integrity": "sha512-mJ5vuDaIZ+l/acv01sHoXfpnyrNKOk/3aDoEdLO/Xtn9HuZlDD6jKxHlkN8ZhWyLJsRBxfv9GYM2utQ1SChKew==", + "cpu": [ + "riscv64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-s390x-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-s390x-gnu/-/resolver-binding-linux-s390x-gnu-1.11.1.tgz", + "integrity": "sha512-kELo8ebBVtb9sA7rMe1Cph4QHreByhaZ2QEADd9NzIQsYNQpt9UkM9iqr2lhGr5afh885d/cB5QeTXSbZHTYPg==", + "cpu": [ + "s390x" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-gnu": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-gnu/-/resolver-binding-linux-x64-gnu-1.11.1.tgz", + "integrity": "sha512-C3ZAHugKgovV5YvAMsxhq0gtXuwESUKc5MhEtjBpLoHPLYM+iuwSj3lflFwK3DPm68660rZ7G8BMcwSro7hD5w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-linux-x64-musl": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-linux-x64-musl/-/resolver-binding-linux-x64-musl-1.11.1.tgz", + "integrity": "sha512-rV0YSoyhK2nZ4vEswT/QwqzqQXw5I6CjoaYMOX0TqBlWhojUf8P94mvI7nuJTeaCkkds3QE4+zS8Ko+GdXuZtA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ] + }, + "node_modules/@unrs/resolver-binding-wasm32-wasi": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-wasm32-wasi/-/resolver-binding-wasm32-wasi-1.11.1.tgz", + "integrity": "sha512-5u4RkfxJm+Ng7IWgkzi3qrFOvLvQYnPBmjmZQ8+szTK/b31fQCnleNl1GgEt7nIsZRIf5PLhPwT0WM+q45x/UQ==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@napi-rs/wasm-runtime": "^0.2.11" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@unrs/resolver-binding-win32-arm64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-arm64-msvc/-/resolver-binding-win32-arm64-msvc-1.11.1.tgz", + "integrity": "sha512-nRcz5Il4ln0kMhfL8S3hLkxI85BXs3o8EYoattsJNdsX4YUU89iOkVn7g0VHSRxFuVMdM4Q1jEpIId1Ihim/Uw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-ia32-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-ia32-msvc/-/resolver-binding-win32-ia32-msvc-1.11.1.tgz", + "integrity": "sha512-DCEI6t5i1NmAZp6pFonpD5m7i6aFrpofcp4LA2i8IIq60Jyo28hamKBxNrZcyOwVOZkgsRp9O2sXWBWP8MnvIQ==", + "cpu": [ + "ia32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/@unrs/resolver-binding-win32-x64-msvc": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@unrs/resolver-binding-win32-x64-msvc/-/resolver-binding-win32-x64-msvc-1.11.1.tgz", + "integrity": "sha512-lrW200hZdbfRtztbygyaq/6jP6AKE8qQN2KvPcJ+x7wiD038YtnYtZ82IMNJ69GJibV7bwL3y9FgK+5w/pYt6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ] + }, + "node_modules/a-sync-waterfall": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/a-sync-waterfall/-/a-sync-waterfall-1.0.1.tgz", + "integrity": "sha512-RYTOHHdWipFUliRFMCS4X2Yn2X8M87V/OpSqWzKKOGhzqyUxzyVmhHDH9sAvG+ZuQf/TAOFsLCpMw09I1ufUnA==", + "dev": true, + "license": "MIT" + }, + "node_modules/acorn": { + "version": "8.16.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.16.0.tgz", + "integrity": "sha512-UVJyE9MttOsBQIDKw1skb9nAwQuR5wuGD3+82K6JgJlm/Y+KI92oNsMNGZCYdDsVtRHSak0pcV5Dno5+4jh9sw==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "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" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-convert": "^2.0.1" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/argparse": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", + "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==", + "dev": true, + "license": "Python-2.0" + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", + "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/array-buffer-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.2.tgz", + "integrity": "sha512-LHE+8BuR7RYGDKvnrmcuSq3tDcKv9OFEXQt/HpbZhY7V6h0zlUXutnAD82GiFx9rdieCMjkvtcsPqBwgUl1Iiw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "is-array-buffer": "^3.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array-includes": { + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.9.tgz", + "integrity": "sha512-FmeCCAenzH0KH381SPT5FZmiA/TmpndpcaShhfgEN9eCVjnFBqq3l1xrI42y8+PPLI6hypzou4GXw00WHmPBLQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.findlastindex": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.6.tgz", + "integrity": "sha512-F/TKATkzseUExPlfvmwQKGITM3DGTK+vkAsCZoDc5daVygbJBnjEUCbgkAvVFsgfXfX4YIqZ/27G3k3tdXrTxQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flat": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flat/-/array.prototype.flat-1.3.3.tgz", + "integrity": "sha512-rwG/ja1neyLqCuGZ5YYrznA62D4mZXg0i1cIskIUKSiqF3Cje9/wXAls9B9s1Wa2fomMsIv8czB8jZcPmxCXFg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.flatmap": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/array.prototype.flatmap/-/array.prototype.flatmap-1.3.3.tgz", + "integrity": "sha512-Y7Wt51eKJSyi80hFrJCePGGNo5ktJCslFuboqJsbf57CCPcm5zztluPlc4/aD8sWsKvlwatezpV4U1efk8kpjg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.5", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/array.prototype.tosorted": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/arraybuffer.prototype.slice": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.4.tgz", + "integrity": "sha512-BNoCY6SXXPQ7gF2opIP4GBE+Xw7U+pHMYKuzjgCN3GwiaIR09UUeKfheyIry77QtrCBlC0KK0q5/TER/tYh3PQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/asap": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/asap/-/asap-2.0.6.tgz", + "integrity": "sha512-BSHWgDSAiKs50o2Re8ppvp3seVHXSRM44cdSsT9FfNEUUZLOGWVCsiWaRPWM1Znn+mqZ1OfVZ3z3DWEzSp7hRA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ast-types-flow": { + "version": "0.0.8", + "resolved": "https://registry.npmjs.org/ast-types-flow/-/ast-types-flow-0.0.8.tgz", + "integrity": "sha512-OH/2E5Fg20h2aPrbe+QL8JZQFko0YZaF+j4mnQ7BGhfavO7OpSLa8a0y9sBwomHdSbkhTS8TQNayBfnW5DwbvQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/async-function": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/async-function/-/async-function-1.0.0.tgz", + "integrity": "sha512-hsU18Ae8CDTR6Kgu9DYf0EbCr/a5iGL0rytQDobUcdpYOKokk8LEjVphnXkDkgpi0wYVsqrXuP0bZxJaTqdgoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/asynckit": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", + "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", + "license": "MIT" + }, + "node_modules/available-typed-arrays": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/axe-core": { + "version": "4.11.4", + "resolved": "https://registry.npmjs.org/axe-core/-/axe-core-4.11.4.tgz", + "integrity": "sha512-KunSNx+TVpkAw/6ULfhnx+HWRecjqZGTOyquAoWHYLRSdK1tB5Ihce1ZW+UY3fj33bYAFWPu7W/GRSmmrCGuxA==", + "dev": true, + "license": "MPL-2.0", + "engines": { + "node": ">=4" + } + }, + "node_modules/axios": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.16.0.tgz", + "integrity": "sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==", + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.16.0", + "form-data": "^4.0.5", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axobject-query": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", + "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/balanced-match": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", + "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.29", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.29.tgz", + "integrity": "sha512-Asa2krT+XTPZINCS+2QcyS8WTkObE77RwkydwF7h6DmnKqbvlalz93m/dnphUyCa6SWSP51VgtEUf2FN+gelFQ==", + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/brace-expansion": { + "version": "1.1.14", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.14.tgz", + "integrity": "sha512-MWPGfDxnyzKU7rNOW9SP/c50vi3xrmrua/+6hfPbCS2ABNWfx24vPidzvC7krjU/RTo235sV776ymlsMtGKj8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/braces": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz", + "integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==", + "dev": true, + "license": "MIT", + "dependencies": { + "fill-range": "^7.1.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "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": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/call-bind": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.9.tgz", + "integrity": "sha512-a/hy+pNsFUTR+Iz8TCJvXudKVLAnz/DyeSUo10I5yvFDQJBFU2s9uqQpoSrJlroHUKoKqzg+epxyP9lqFdzfBQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "es-define-property": "^1.0.1", + "get-intrinsic": "^1.3.0", + "set-function-length": "^1.2.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-bind-apply-helpers": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz", + "integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/call-bound": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz", + "integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.2", + "get-intrinsic": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/call-me-maybe": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/call-me-maybe/-/call-me-maybe-1.0.2.tgz", + "integrity": "sha512-HpX65o1Hnr9HH25ojC1YGs7HCQLq0GCOibSaWER0eNpgJ/Z1MZv2mTc7+xh6WOPxbRVcmgbv4hGU+uSQ/2xFZQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/callsites": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/callsites/-/callsites-3.1.0.tgz", + "integrity": "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001792", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001792.tgz", + "integrity": "sha512-hVLMUZFgR4JJ6ACt1uEESvQN1/dBVqPAKY0hgrV70eN3391K6juAfTjKZLKvOMsx8PxA7gsY1/tLMMTcfFLLpw==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chalk": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/chalk/-/chalk-4.1.2.tgz", + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.1.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/chalk?sponsor=1" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/client-only": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz", + "integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==", + "license": "MIT" + }, + "node_modules/cliui": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/cliui/-/cliui-8.0.1.tgz", + "integrity": "sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==", + "dev": true, + "license": "ISC", + "dependencies": { + "string-width": "^4.2.0", + "strip-ansi": "^6.0.1", + "wrap-ansi": "^7.0.0" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/color-convert": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "color-name": "~1.1.4" + }, + "engines": { + "node": ">=7.0.0" + } + }, + "node_modules/color-name": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/color-name/-/color-name-1.1.4.tgz", + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==", + "dev": true, + "license": "MIT" + }, + "node_modules/combined-stream": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", + "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", + "license": "MIT", + "dependencies": { + "delayed-stream": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/commander": { + "version": "14.0.3", + "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.3.tgz", + "integrity": "sha512-H+y0Jo/T1RZ9qPP4Eh1pkcQcLRglraJaSLoyOtHxu6AapkjWVCy2Sit1QQ4x3Dng8qDlSsZEet7g5Pq06MvTgw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/concat-map": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", + "integrity": "sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==", + "dev": true, + "license": "MIT" + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cosmiconfig": { + "version": "9.0.1", + "resolved": "https://registry.npmjs.org/cosmiconfig/-/cosmiconfig-9.0.1.tgz", + "integrity": "sha512-hr4ihw+DBqcvrsEDioRO31Z17x71pUYoNe/4h6Z0wB72p7MU7/9gH8Q3s12NFhHPfYBBOV3qyfUxmr/Yn3shnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "env-paths": "^2.2.1", + "import-fresh": "^3.3.0", + "js-yaml": "^4.1.0", + "parse-json": "^5.2.0" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/sponsors/d-fischer" + }, + "peerDependencies": { + "typescript": ">=4.9.5" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/d": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/d/-/d-1.0.2.tgz", + "integrity": "sha512-MOqHvMWF9/9MX6nza0KgvFH4HpMU0EF5uUDXqX/BtxtU8NfB0QzRtJ8Oe/6SuS4kbhyzVJwjd97EA4PKrzJ8bw==", + "dev": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/damerau-levenshtein": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/damerau-levenshtein/-/damerau-levenshtein-1.0.8.tgz", + "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/data-view-buffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.2.tgz", + "integrity": "sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.2.tgz", + "integrity": "sha512-tuhGbE6CfTM9+5ANGf+oQb72Ky/0+s3xKUpHvShfiz2RxMFgFPjsXuRLBVMtvMs15awe45SRb83D6wH4ew6wlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/inspect-js" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.1.tgz", + "integrity": "sha512-BS8PfmtDGnrgYdOonGZQdLZslWIeCGFP9tpan0hi1Co2Zr2NKADsvGYA8XxuG/4UWgJ6Cjtv+YJnB6MM69QGlQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/date-fns": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/date-fns/-/date-fns-4.1.0.tgz", + "integrity": "sha512-Ukq0owbQXxa/U3EGtsdVBkR1w7KOQ5gIBqdH2hkvknzZPYvBxb/aa6E8L7tmjFtkwZBu3UXBbjIgPo/Ez4xaNg==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/kossnocorp" + } + }, + "node_modules/date-fns-jalali": { + "version": "4.1.0-0", + "resolved": "https://registry.npmjs.org/date-fns-jalali/-/date-fns-jalali-4.1.0-0.tgz", + "integrity": "sha512-hTIP/z+t+qKwBDcmmsnmjWTduxCg+5KfdqWQvb2X/8C9+knYY6epN/pfxdDuyVlSVeFz0sM5eEfwIUQ70U4ckg==", + "license": "MIT" + }, + "node_modules/dayjs": { + "version": "1.11.20", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.20.tgz", + "integrity": "sha512-YbwwqR/uYpeoP4pu043q+LTDLFBLApUP6VxRihdfNTqu4ubqMlGDLd6ErXhEgsyvY0K6nCs7nggYumAN+9uEuQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/define-data-property": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", + "integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/define-properties": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz", + "integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.0.1", + "has-property-descriptors": "^1.0.0", + "object-keys": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/delayed-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", + "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", + "license": "MIT", + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "devOptional": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/doctrine": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/doctrine/-/doctrine-2.1.0.tgz", + "integrity": "sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "esutils": "^2.0.2" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/dunder-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz", + "integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==", + "license": "MIT", + "dependencies": { + "call-bind-apply-helpers": "^1.0.1", + "es-errors": "^1.3.0", + "gopd": "^1.2.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/electron-to-chromium": { + "version": "1.5.353", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.353.tgz", + "integrity": "sha512-kOrWphBi8TOZyiJZqsgqIle0lw+tzmnQK83pV9dZUd01Nm2POECSyFQMAuarzZdYqQW7FH9RaYOuaRo3h+bQ3w==", + "dev": true, + "license": "ISC" + }, + "node_modules/emoji-regex": { + "version": "9.2.2", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-9.2.2.tgz", + "integrity": "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==", + "dev": true, + "license": "MIT" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.2", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.2.tgz", + "integrity": "sha512-xe9vQb5kReirPUxgQrXA3ihgbCqssmTiM7cOZ+Gzu+VeGWgpV98lLZvp0dl4yriyAePcewxGUs9UpKD8PET9KQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/env-paths": { + "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/error-ex": { + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/error-ex/-/error-ex-1.3.4.tgz", + "integrity": "sha512-sqQamAnR14VgCr1A618A3sGrygcpK+HEbenA/HiEAkkUwcZIIB/tgWqHFxWgOyDh4nB4JCRimh79dR5Ywc9MDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-arrayish": "^0.2.1" + } + }, + "node_modules/es-abstract": { + "version": "1.24.2", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.24.2.tgz", + "integrity": "sha512-2FpH9Q5i2RRwyEP1AylXe6nYLR5OhaJTZwmlcP0dL/+JCbgg7yyEo/sEK6HeGZRf3dFpWwThaRHVApXSkW3xeg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es-define-property": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", + "integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-errors": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", + "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-iterator-helpers": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.3.2.tgz", + "integrity": "sha512-HVLACW1TppGYjJ8H6/jqH/pqOtKRw6wMlrB23xfExmFWxFquAIWCmwoLsOyN96K4a5KbmOf5At9ZUO3GZbetAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-abstract": "^1.24.2", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.1.0", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.3.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", + "internal-slot": "^1.1.0", + "iterator.prototype": "^1.1.5", + "math-intrinsics": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz", + "integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-set-tostringtag": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", + "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-shim-unscopables": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/es-shim-unscopables/-/es-shim-unscopables-1.1.0.tgz", + "integrity": "sha512-d9T8ucsEhh8Bi1woXCf+TIKDIROLG5WCkxg8geBCbvk22kzwC5G2OnXVMO6FUsvQlgUUXQ2itephWDLqDzbeCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-to-primitive": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/es-to-primitive/-/es-to-primitive-1.3.0.tgz", + "integrity": "sha512-w+5mJ3GuFL+NjVtJlvydShqE1eN3h3PbI7/5LAsYJP/2qtuMXjfL2LpHSRqo4b4eSF5K/DH1JXKUAHSB2UW50g==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7", + "is-date-object": "^1.0.5", + "is-symbol": "^1.0.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/es5-ext": { + "version": "0.10.64", + "resolved": "https://registry.npmjs.org/es5-ext/-/es5-ext-0.10.64.tgz", + "integrity": "sha512-p2snDhiLaXe6dahss1LddxqEm+SkuDvV8dnIQG0MWjyHpcMNfXKPE+/Cc0y+PhxJX3A4xGNeFCj5oc0BUh6deg==", + "dev": true, + "hasInstallScript": true, + "license": "ISC", + "dependencies": { + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.3", + "esniff": "^2.0.1", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/es6-iterator": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-iterator/-/es6-iterator-2.0.3.tgz", + "integrity": "sha512-zw4SRzoUkd+cl+ZoE15A9o1oQd920Bb0iOJMQkQhl3jNc03YqVjAhG7scf9C5KWRU/R13Orf588uCC6525o02g==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.35", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/es6-promise": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/es6-promise/-/es6-promise-3.3.1.tgz", + "integrity": "sha512-SOp9Phqvqn7jtEUxPWdWfWoLmyt2VaJ6MpvP9Comy1MceMXqE6bxvaTu4iaxpYYPzhny28Lc+M87/c2cPK6lDg==", + "dev": true, + "license": "MIT" + }, + "node_modules/es6-symbol": { + "version": "3.1.4", + "resolved": "https://registry.npmjs.org/es6-symbol/-/es6-symbol-3.1.4.tgz", + "integrity": "sha512-U9bFFjX8tFiATgtkJ1zg25+KviIXpgRvRHS8sau3GfhVzThRQrOeksPeT0BWW2MNZs1OEWJ1DPXOQMn0KKRkvg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.2", + "ext": "^1.7.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/es6-weak-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es6-weak-map/-/es6-weak-map-2.0.3.tgz", + "integrity": "sha512-p5um32HOTO1kP+w7PRnB+5lQ43Z6muuMuIMffvDN8ZB4GcnjLBV6zGStpbASIMk4DCAvEaamhe2zhyCb/QXXsA==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "1", + "es5-ext": "^0.10.46", + "es6-iterator": "^2.0.3", + "es6-symbol": "^3.1.1" + } + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "9.39.4", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.39.4.tgz", + "integrity": "sha512-XoMjdBOwe/esVgEvLmNsD3IRHkm7fbKIUGvrleloJXUZgDHig2IPWNniv+GwjyJXzuNqVjlr5+4yVUZjycJwfQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.1", + "@eslint/config-array": "^0.21.2", + "@eslint/config-helpers": "^0.4.2", + "@eslint/core": "^0.17.0", + "@eslint/eslintrc": "^3.3.5", + "@eslint/js": "9.39.4", + "@eslint/plugin-kit": "^0.4.1", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "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.5", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-config-next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/eslint-config-next/-/eslint-config-next-16.2.3.tgz", + "integrity": "sha512-Dnkrylzjof/Az7iNoIQJqD18zTxQZcngir19KJaiRsMnnjpQSVoa6aEg/1Q4hQC+cW90uTlgQYadwL1CYNwFWA==", + "dev": true, + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "typescript": { + "optional": true + } + } + }, + "node_modules/eslint-config-next/node_modules/globals": { + "version": "16.4.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-16.4.0.tgz", + "integrity": "sha512-ob/2LcVVaVGCYN+r14cnwnoDPUufjiYgSqRhiFD0Q1iI4Odora5RE8Iv1D24hAz5oMophRGkGz+yuvQmmUMnMw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint-config-prettier": { + "version": "10.1.8", + "resolved": "https://registry.npmjs.org/eslint-config-prettier/-/eslint-config-prettier-10.1.8.tgz", + "integrity": "sha512-82GZUjRS0p/jganf6q1rEO25VSoHH0hKPCTrgillPjdI/3bgBhAE1QzHrHTizjpRvy6pGAvKjDJtk2pF9NDq8w==", + "dev": true, + "license": "MIT", + "bin": { + "eslint-config-prettier": "bin/cli.js" + }, + "funding": { + "url": "https://opencollective.com/eslint-config-prettier" + }, + "peerDependencies": { + "eslint": ">=7.0.0" + } + }, + "node_modules/eslint-import-resolver-node": { + "version": "0.3.10", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-node/-/eslint-import-resolver-node-0.3.10.tgz", + "integrity": "sha512-tRrKqFyCaKict5hOd244sL6EQFNycnMQnBe+j8uqGNXYzsImGbGUU4ibtoaBmv5FLwJwcFJNeg1GeVjQfbMrDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7", + "is-core-module": "^2.16.1", + "resolve": "^2.0.0-next.6" + } + }, + "node_modules/eslint-import-resolver-node/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-import-resolver-typescript": { + "version": "3.10.1", + "resolved": "https://registry.npmjs.org/eslint-import-resolver-typescript/-/eslint-import-resolver-typescript-3.10.1.tgz", + "integrity": "sha512-A1rHYb06zjMGAxdLSkN2fXPBwuSaQ0iO5M/hdyS0Ajj1VBaRp0sPD3dn1FhME3c/JluGFbwSxyCfqdSbtQLAHQ==", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": "^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint-import-resolver-typescript" + }, + "peerDependencies": { + "eslint": "*", + "eslint-plugin-import": "*", + "eslint-plugin-import-x": "*" + }, + "peerDependenciesMeta": { + "eslint-plugin-import": { + "optional": true + }, + "eslint-plugin-import-x": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils": { + "version": "2.12.1", + "resolved": "https://registry.npmjs.org/eslint-module-utils/-/eslint-module-utils-2.12.1.tgz", + "integrity": "sha512-L8jSWTze7K2mTg0vos/RuLRS5soomksDPoJLXIslC7c8Wmut3bx7CPpJijDcBZtxQ5lrbUdM+s0OlNbz0DCDNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "debug": "^3.2.7" + }, + "engines": { + "node": ">=4" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/eslint-module-utils/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-import": { + "version": "2.32.0", + "resolved": "https://registry.npmjs.org/eslint-plugin-import/-/eslint-plugin-import-2.32.0.tgz", + "integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-import/node_modules/debug": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-3.2.7.tgz", + "integrity": "sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.1" + } + }, + "node_modules/eslint-plugin-jsx-a11y": { + "version": "6.10.2", + "resolved": "https://registry.npmjs.org/eslint-plugin-jsx-a11y/-/eslint-plugin-jsx-a11y-6.10.2.tgz", + "integrity": "sha512-scB3nz4WmG75pV8+3eRUQOHZlNSUhFNq37xnpgRkCCELU3XMvXAxLk1eqWWyE22Ki4Q01Fnsw9BA3cJHDPgn2Q==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4.0" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9" + } + }, + "node_modules/eslint-plugin-react": { + "version": "7.37.5", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.37.5.tgz", + "integrity": "sha512-Qteup0SqU15kdocexFNAJMvCJEfa2xUKNV4CC1xsVMrIIqEy3SQ/rqyxCWNzfrd3/ldy6HMlD2e0JDVpDg2qIA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=4" + }, + "peerDependencies": { + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-scope": { + "version": "8.4.0", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", + "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", + "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esniff": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/esniff/-/esniff-2.0.1.tgz", + "integrity": "sha512-kTUIGKQ/mDPFoJ0oVfcmyJn4iBDRptjNVIzwIFR7tqWXdVI9xfA2RMwY/gbSpJG3lkdWNEjLap/NqVHZiJsdfg==", + "dev": true, + "license": "ISC", + "dependencies": { + "d": "^1.0.1", + "es5-ext": "^0.10.62", + "event-emitter": "^0.3.5", + "type": "^2.7.2" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/espree": { + "version": "10.4.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", + "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.15.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^4.2.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/event-emitter": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/event-emitter/-/event-emitter-0.3.5.tgz", + "integrity": "sha512-D9rRn9y7kLPnJ+hMq7S/nhvoKwwvVJahBi2BPmx3bvbsEdK3W9ii8cBSGjP+72/LnM4n6fo3+dkCX5FeTQruXA==", + "dev": true, + "license": "MIT", + "dependencies": { + "d": "1", + "es5-ext": "~0.10.14" + } + }, + "node_modules/ext": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/ext/-/ext-1.7.0.tgz", + "integrity": "sha512-6hxeJYaL110a9b5TEJSj0gojyHQAmA2ch5Os+ySCiA1QGdS697XWY1pzsrSjqA9LDEEgdB/KypIlR59RcLuHYw==", + "dev": true, + "license": "ISC", + "dependencies": { + "type": "^2.7.2" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-glob": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.1.tgz", + "integrity": "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8.6.0" + } + }, + "node_modules/fast-glob/node_modules/glob-parent": { + "version": "5.1.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz", + "integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==", + "dev": true, + "license": "MIT" + }, + "node_modules/fastq": { + "version": "1.20.1", + "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.20.1.tgz", + "integrity": "sha512-GGToxJ/w1x32s/D2EKND7kTil4n8OVk/9mycTc4VDza13lOvpUZTGX3mFSCtV9ksdGBVzvsyAVLM6mHFThxXxw==", + "dev": true, + "license": "ISC", + "dependencies": { + "reusify": "^1.0.4" + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/fill-range": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", + "integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==", + "dev": true, + "license": "MIT", + "dependencies": { + "to-regex-range": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/follow-redirects": { + "version": "1.16.0", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", + "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", + "funding": [ + { + "type": "individual", + "url": "https://github.com/sponsors/RubenVerborgh" + } + ], + "license": "MIT", + "engines": { + "node": ">=4.0" + }, + "peerDependenciesMeta": { + "debug": { + "optional": true + } + } + }, + "node_modules/for-each": { + "version": "0.3.5", + "resolved": "https://registry.npmjs.org/for-each/-/for-each-0.3.5.tgz", + "integrity": "sha512-dKx12eRCVIzqCxFGplyFKJMPvLEWgmNtUrpTiJIR5u97zEhRG8ySrtboPHZXx7daLxQVrl643cTzbab2tkQjxg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-callable": "^1.2.7" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/form-data": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.5.tgz", + "integrity": "sha512-8RipRLol37bNs2bhoV67fiTEvdTrbMUYcFTiy3+wuuOnUog2QBHCZWXDRijWQfAkhBj2Uf5UnVaiWwA5vdd82w==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/framer-motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-12.38.0.tgz", + "integrity": "sha512-rFYkY/pigbcswl1XQSb7q424kSTQ8q6eAC+YUsSKooHQYuLdzdHjrt6uxUC+PRAO++q5IS7+TamgIw1AphxR+g==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/fs.realpath": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", + "integrity": "sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==", + "dev": true, + "license": "ISC" + }, + "node_modules/function-bind": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", + "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/function.prototype.name": { + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/function.prototype.name/-/function.prototype.name-1.1.8.tgz", + "integrity": "sha512-e5iwyodOHhbMr/yNrc7fDYG4qlbIvI5gajyzPnb5TCwyhjApznQh1BMFou9b30SevY43gCJKXycoCBjMbsuW0Q==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/functions-have-names": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz", + "integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/generator-function": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", + "integrity": "sha512-SFdFmIJi+ybC0vjlHN0ZGVGHc3lgE0DxPAT0djjVg+kjOnSqclqmj0KQ7ykTOLP6YxoqOvuAODGdcHJn+43q3g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-caller-file": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/get-caller-file/-/get-caller-file-2.0.5.tgz", + "integrity": "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==", + "dev": true, + "license": "ISC", + "engines": { + "node": "6.* || 8.* || >= 10.*" + } + }, + "node_modules/get-intrinsic": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz", + "integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/get-proto": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz", + "integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==", + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/get-symbol-description": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.1.0.tgz", + "integrity": "sha512-w9UMqWwJxHNOvoNzSJ2oPF5wvYcvP7jUvYzhp67yEhTi17ZDBBC1z9pTdGuzjD+EFIqLSYRweZjqfiPzQ06Ebg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/get-tsconfig": { + "version": "4.14.0", + "resolved": "https://registry.npmjs.org/get-tsconfig/-/get-tsconfig-4.14.0.tgz", + "integrity": "sha512-yTb+8DXzDREzgvYmh6s9vHsSVCHeC0G3PI5bEXNBHtmshPnO+S5O7qgLEOn0I5QvMy6kpZN8K1NKGyilLb93wA==", + "dev": true, + "license": "MIT", + "dependencies": { + "resolve-pkg-maps": "^1.0.0" + }, + "funding": { + "url": "https://github.com/privatenumber/get-tsconfig?sponsor=1" + } + }, + "node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Old versions of glob are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": "*" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-14.0.0.tgz", + "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/globalthis": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/globalthis/-/globalthis-1.0.4.tgz", + "integrity": "sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.2.1", + "gopd": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/gopd": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz", + "integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-bigints": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-bigints/-/has-bigints-1.1.0.tgz", + "integrity": "sha512-R3pbpkcIqv2Pm3dUwgjclDRVmWpTJW2DcMzcIhEXEx1oh/CEMObMm3KLmRJOdvhM7o4uQBnwr8pzRK2sJWIqfg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/has-property-descriptors": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz", + "integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-define-property": "^1.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-proto": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.2.0.tgz", + "integrity": "sha512-KIL7eQPfHQRC8+XluaIw7BHUwwqL19bQn4hzNgdr+1wXoU0KKj6rufu47lhY7KbJR2C6T6+PfyN0Ea7wkSS+qQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-symbols": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz", + "integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/has-tostringtag": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", + "license": "MIT", + "dependencies": { + "has-symbols": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/hasown": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.3.tgz", + "integrity": "sha512-ej4AhfhfL2Q2zpMmLo7U1Uv9+PyhIZpgQLGT1F9miIGmiCJIoCgSmczFdrc97mWT4kVY72KA+WnnhJ5pghSvSg==", + "license": "MIT", + "dependencies": { + "function-bind": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/http2-client": { + "version": "1.3.5", + "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", + "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/immediate": { + "version": "3.0.6", + "resolved": "https://registry.npmjs.org/immediate/-/immediate-3.0.6.tgz", + "integrity": "sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==", + "license": "MIT" + }, + "node_modules/immer": { + "version": "10.2.0", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz", + "integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, + "node_modules/import-fresh": { + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", + "integrity": "sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "parent-module": "^1.0.0", + "resolve-from": "^4.0.0" + }, + "engines": { + "node": ">=6" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/inflight": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", + "integrity": "sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==", + "deprecated": "This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful.", + "dev": true, + "license": "ISC", + "dependencies": { + "once": "^1.3.0", + "wrappy": "1" + } + }, + "node_modules/inherits": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/internal-slot": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.1.0.tgz", + "integrity": "sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "hasown": "^2.0.2", + "side-channel": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/is-array-buffer": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz", + "integrity": "sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-arrayish": { + "version": "0.2.1", + "resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.2.1.tgz", + "integrity": "sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-async-function": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-async-function/-/is-async-function-2.1.1.tgz", + "integrity": "sha512-9dgM/cZBnNvjzaMYHVoxxfPj2QXt22Ev7SuuPrs+xav0ukGB0S6d4ydZdEiM48kLx5kDV+QBPrpVnFyefL8kkQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bigint": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-bigint/-/is-bigint-1.1.0.tgz", + "integrity": "sha512-n4ZT37wG78iz03xPRKJrHTdZbe3IicyucEtdRsV5yglwc3GyUfbAfpSeD0FJ41NbUNSt5wbhqfp1fS+BgnvDFQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-bigints": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-boolean-object": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/is-boolean-object/-/is-boolean-object-1.2.2.tgz", + "integrity": "sha512-wa56o2/ElJMYqjCjGkXri7it5FbebW5usLw/nPmCMs5DeZ7eziSYZhSmPRn0txqeW4LnAmQQU7FgqLpsEFKM4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-bun-module": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/is-bun-module/-/is-bun-module-2.0.0.tgz", + "integrity": "sha512-gNCGbnnnnFAUGKeZ9PdbyeGYJqewpmc2aKHUEMO5nQPWU9lOmv7jcmQIv+qHD8fXW6W7qfuCwX4rY9LNRjXrkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.7.1" + } + }, + "node_modules/is-bun-module/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/is-callable": { + "version": "1.2.7", + "resolved": "https://registry.npmjs.org/is-callable/-/is-callable-1.2.7.tgz", + "integrity": "sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-core-module": { + "version": "2.16.2", + "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", + "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hasown": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-data-view": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.2.tgz", + "integrity": "sha512-RKtWF8pGmS87i2D6gqQu/l7EYRlVdfzemCJN/P3UOs//x1QE7mfhvzHIApBTRf7axvT6DMGwSwBXYCT0nfB9xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "get-intrinsic": "^1.2.6", + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-date-object": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz", + "integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-finalizationregistry": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-finalizationregistry/-/is-finalizationregistry-1.1.1.tgz", + "integrity": "sha512-1pC6N8qWJbWoPtEjgcL2xyhQOP491EQjeUo3qTKcmV8YSDDJrOepfG8pcC7h/QgnQHYSv0mJ3Z/ZWxmatVrysg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-fullwidth-code-point": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-generator-function": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/is-generator-function/-/is-generator-function-1.1.2.tgz", + "integrity": "sha512-upqt1SkGkODW9tsGNG5mtXTXtECizwtS2kA161M+gJPc1xdb/Ax629af6YrTwcOeQHbewrPNlE5Dx7kzvXTizA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.4", + "generator-function": "^2.0.0", + "get-proto": "^1.0.1", + "has-tostringtag": "^1.0.2", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-negative-zero": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-number": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", + "integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.12.0" + } + }, + "node_modules/is-number-object": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-number-object/-/is-number-object-1.1.1.tgz", + "integrity": "sha512-lZhclumE1G6VYD8VHe35wFaIif+CTy5SJIi5+3y4psDgWu4wPDoBhF8NxUOinEc7pHgiTsT6MaBb92rKhhD+Xw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-promise": { + "version": "2.2.2", + "resolved": "https://registry.npmjs.org/is-promise/-/is-promise-2.2.2.tgz", + "integrity": "sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/is-regex": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz", + "integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "gopd": "^1.2.0", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-set": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-shared-array-buffer": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.4.tgz", + "integrity": "sha512-ISWac8drv4ZGfwKl5slpHG9OwPNty4jOWPRIhBpxOoD+hqITiwuipOQ2bNthAzwA3B4fIjO4Nln74N0S9byq8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-string": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-string/-/is-string-1.1.1.tgz", + "integrity": "sha512-BtEeSsoaQjlSPBemMQIrY1MY0uM6vnS1g5fmufYOtnxLGUZM2178PKbhsk7Ffv58IX+ZtcvoGwccYsh0PglkAA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-tostringtag": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-symbol": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-symbol/-/is-symbol-1.1.1.tgz", + "integrity": "sha512-9gGx6GTtCQM73BgmHQXfDmLtfjjTUDSyoxTCbp5WtoixAhfgsDirWIcVQ/IHpvI5Vgd5i/J5F7B9cN/WlVbC/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "has-symbols": "^1.1.0", + "safe-regex-test": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-typed-array": { + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.15.tgz", + "integrity": "sha512-p3EcsicXjit7SaskXHs1hA91QxgTw46Fv6EFKKGS5DRFLD8yKnohjF3hxoju94b/OcMZoQukzpPpBE9uLVKzgQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "which-typed-array": "^1.1.16" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakmap": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakref": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/is-weakref/-/is-weakref-1.1.1.tgz", + "integrity": "sha512-6i9mGWSlqzNMEqpCp93KwRS1uUOodk2OJ6b+sq7ZPDSy2WuI5NFIxp/254TytR8ftefexkWn5xNiHUNpPOfSew==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/is-weakset": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.4.tgz", + "integrity": "sha512-mfcwb6IzQyOKTs84CQMrOwW4gQcaTOAWJ0zzJCl2WSPDrWk/OzDaImWFH3djXhb24g4eudZfLRozAvPGw4d9hQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "get-intrinsic": "^1.2.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/isarray": { + "version": "2.0.5", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-2.0.5.tgz", + "integrity": "sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/iterator.prototype": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/iterator.prototype/-/iterator.prototype-1.1.5.tgz", + "integrity": "sha512-H0dkQoCa3b2VEeKQBOxFph+JAbcrQdE7KC0UkqwpLmv2EC4P41QXP+rqo9wYodACiG5/WM5s9oDApTU8utwj9g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/js-yaml": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.1.tgz", + "integrity": "sha512-qQKT4zQxXl8lLwBtHMWwaTcGfFOZviOJet3Oy/xmGk2gZH677CJM9EvtfdSkgWcATZhj/55JZ0rmy3myCT5lsA==", + "dev": true, + "license": "MIT", + "dependencies": { + "argparse": "^2.0.1" + }, + "bin": { + "js-yaml": "bin/js-yaml.js" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-parse-even-better-errors": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-2.3.1.tgz", + "integrity": "sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/jsx-ast-utils": { + "version": "3.3.5", + "resolved": "https://registry.npmjs.org/jsx-ast-utils/-/jsx-ast-utils-3.3.5.tgz", + "integrity": "sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "array-includes": "^3.1.6", + "array.prototype.flat": "^1.3.1", + "object.assign": "^4.1.4", + "object.values": "^1.1.6" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/language-subtag-registry": { + "version": "0.3.23", + "resolved": "https://registry.npmjs.org/language-subtag-registry/-/language-subtag-registry-0.3.23.tgz", + "integrity": "sha512-0K65Lea881pHotoGEa5gDlMxt3pctLi2RplBb7Ezh4rRdLEOtgi7n4EwK9lamnUCkKBqaeKRVebTq6BAxSkpXQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/language-tags": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/language-tags/-/language-tags-1.0.9.tgz", + "integrity": "sha512-MbjN408fEndfiQXbFQ1vnd+1NoLDsnQW41410oQBXiyXDMYH5z505juWa4KUE1LqxRC7DgOgZDbKLxHIwm27hA==", + "dev": true, + "license": "MIT", + "dependencies": { + "language-subtag-registry": "^0.3.20" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lie": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/lie/-/lie-3.1.1.tgz", + "integrity": "sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==", + "license": "MIT", + "dependencies": { + "immediate": "~3.0.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lines-and-columns": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz", + "integrity": "sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==", + "dev": true, + "license": "MIT" + }, + "node_modules/localforage": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/localforage/-/localforage-1.10.0.tgz", + "integrity": "sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==", + "license": "Apache-2.0", + "dependencies": { + "lie": "3.1.1" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lodash": { + "version": "4.18.1", + "resolved": "https://registry.npmjs.org/lodash/-/lodash-4.18.1.tgz", + "integrity": "sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/lodash.merge": { + "version": "4.6.2", + "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", + "integrity": "sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lru-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/lru-queue/-/lru-queue-0.1.0.tgz", + "integrity": "sha512-BpdYkt9EvGl8OfWHDQPISVpcl5xZthb+XPsbELj5AQXxIC8IriDZIQYjBJPEm5rS420sjZ0TLEzRcq5KdBhYrQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es5-ext": "~0.10.2" + } + }, + "node_modules/lucide-react": { + "version": "0.523.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.523.0.tgz", + "integrity": "sha512-rUjQoy7egZT9XYVXBK1je9ckBnNp7qzRZOhLQx5RcEp2dCGlXo+mv6vf7Am4LimEcFBJIIZzSGfgTqc9QCrPSw==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/math-intrinsics": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz", + "integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==", + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/memoizee": { + "version": "0.4.17", + "resolved": "https://registry.npmjs.org/memoizee/-/memoizee-0.4.17.tgz", + "integrity": "sha512-DGqD7Hjpi/1or4F/aYAspXKNm5Yili0QDAFAY4QYvpqpgiY6+1jOfqpmByzjxbWd/T9mChbCArXAbDAsTm5oXA==", + "dev": true, + "license": "ISC", + "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" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/merge2": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/merge2/-/merge2-1.4.1.tgz", + "integrity": "sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 8" + } + }, + "node_modules/micromatch": { + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", + "dev": true, + "license": "MIT", + "dependencies": { + "braces": "^3.0.3", + "picomatch": "^2.3.1" + }, + "engines": { + "node": ">=8.6" + } + }, + "node_modules/mime-db": { + "version": "1.52.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.52.0.tgz", + "integrity": "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==", + "license": "MIT", + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/mime-types": { + "version": "2.1.35", + "resolved": "https://registry.npmjs.org/mime-types/-/mime-types-2.1.35.tgz", + "integrity": "sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==", + "license": "MIT", + "dependencies": { + "mime-db": "1.52.0" + }, + "engines": { + "node": ">= 0.6" + } + }, + "node_modules/minimatch": { + "version": "3.1.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", + "integrity": "sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==", + "dev": true, + "license": "ISC", + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/mock.js": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/mock.js/-/mock.js-0.2.0.tgz", + "integrity": "sha512-DKI8Rh/h7Mma+fg+6aD0uUvwn0QXAjKG6q3s+lTaCboCQ/kvQMBN9IXRBzgEaz4aPiYoRnKU9jVsfZp0mHpWrQ==", + "dev": true + }, + "node_modules/mockjs": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/mockjs/-/mockjs-1.1.0.tgz", + "integrity": "sha512-eQsKcWzIaZzEZ07NuEyO4Nw65g0hdWAyurVol1IPl1gahRwY+svqzfgfey8U8dahLwG44d6/RwEzuK52rSa/JQ==", + "dev": true, + "dependencies": { + "commander": "*" + }, + "bin": { + "random": "bin/random" + } + }, + "node_modules/motion": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion/-/motion-12.38.0.tgz", + "integrity": "sha512-uYfXzeHlgThchzwz5Te47dlv5JOUC7OB4rjJ/7XTUgtBZD8CchMN8qEJ4ZVsUmTyYA44zjV0fBwsiktRuFnn+w==", + "license": "MIT", + "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" + }, + "peerDependenciesMeta": { + "@emotion/is-prop-valid": { + "optional": true + }, + "react": { + "optional": true + }, + "react-dom": { + "optional": true + } + } + }, + "node_modules/motion-dom": { + "version": "12.38.0", + "resolved": "https://registry.npmjs.org/motion-dom/-/motion-dom-12.38.0.tgz", + "integrity": "sha512-pdkHLD8QYRp8VfiNLb8xIBJis1byQ9gPT3Jnh2jqfFtAsWUA3dEepDlsWe/xMpO8McV+VdpKVcp+E+TGJEtOoA==", + "license": "MIT", + "dependencies": { + "motion-utils": "^12.36.0" + } + }, + "node_modules/motion-utils": { + "version": "12.36.0", + "resolved": "https://registry.npmjs.org/motion-utils/-/motion-utils-12.36.0.tgz", + "integrity": "sha512-eHWisygbiwVvf6PZ1vhaHCLamvkSbPIeAYxWUuL3a2PD/TROgE7FvfHWTIH4vMl798QLfMw15nRqIaRDXTlYRg==", + "license": "MIT" + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/napi-postinstall": { + "version": "0.3.4", + "resolved": "https://registry.npmjs.org/napi-postinstall/-/napi-postinstall-0.3.4.tgz", + "integrity": "sha512-PHI5f1O0EP5xJ9gQmFGMS6IZcrVvTjpXjz7Na41gTE7eE2hK11lg04CECCYEEjdc17EV4DO+fkGEtt7TpTaTiQ==", + "dev": true, + "license": "MIT", + "bin": { + "napi-postinstall": "lib/cli.js" + }, + "engines": { + "node": "^12.20.0 || ^14.18.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/napi-postinstall" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/next": { + "version": "16.2.3", + "resolved": "https://registry.npmjs.org/next/-/next-16.2.3.tgz", + "integrity": "sha512-9V3zV4oZFza3PVev5/poB9g0dEafVcgNyQ8eTRop8GvxZjV2G15FC5ARuG1eFD42QgeYkzJBJzHghNP8Ad9xtA==", + "license": "MIT", + "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" + }, + "bin": { + "next": "dist/bin/next" + }, + "engines": { + "node": ">=20.9.0" + }, + "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" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@playwright/test": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + }, + "sass": { + "optional": true + } + } + }, + "node_modules/next-tick": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/next-tick/-/next-tick-1.1.0.tgz", + "integrity": "sha512-CXdUiJembsNjuToQvxayPZF9Vqht7hewsvy2sOWafLvi2awflj9mOC6bHIg50orX8IJvWKY9wYQ/zB2kogPslQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/next/node_modules/postcss": { + "version": "8.4.31", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz", + "integrity": "sha512-PS08Iboia9mts/2ygV3eLpY5ghnUcfLV/EXTOW1E2qYxJKGGBUtNjN76FYHnMs36RmARn41bC0AZmn+rR0OVpQ==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.6", + "picocolors": "^1.0.0", + "source-map-js": "^1.0.2" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/node-exports-info": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", + "integrity": "sha512-pyFS63ptit/P5WqUkt+UUfe+4oevH+bFeIiPPdfb0pFeYEu/1ELnJu5l+5EcTKYL5M7zaAa7S8ddywgXypqKCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "array.prototype.flatmap": "^1.3.3", + "es-errors": "^1.3.0", + "object.entries": "^1.1.9", + "semver": "^6.3.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/node-fetch-h2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", + "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", + "dev": true, + "license": "MIT", + "dependencies": { + "http2-client": "^1.2.5" + }, + "engines": { + "node": "4.x || >=6.0.0" + } + }, + "node_modules/node-readfiles": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/node-readfiles/-/node-readfiles-0.2.0.tgz", + "integrity": "sha512-SU00ZarexNlE4Rjdm83vglt5Y9yiQ+XI1XpflWlb7q7UTN1JUItm69xMeiQCTxtTfnzt+83T8Cx+vI2ED++VDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es6-promise": "^3.2.1" + } + }, + "node_modules/node-releases": { + "version": "2.0.38", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.38.tgz", + "integrity": "sha512-3qT/88Y3FbH/Kx4szpQQ4HzUbVrHPKTLVpVocKiLfoYvw9XSGOX2FmD2d6DrXbVYyAQTF2HeF6My8jmzx7/CRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/number-to-words": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/number-to-words/-/number-to-words-1.2.4.tgz", + "integrity": "sha512-/fYevVkXRcyBiZDg6yzZbm0RuaD6i0qRfn8yr+6D0KgBMOndFPxuW10qCHpzs50nN8qKuv78k8MuotZhcVX6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/nunjucks": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/nunjucks/-/nunjucks-3.2.4.tgz", + "integrity": "sha512-26XRV6BhkgK0VOxfbU5cQI+ICFUtMLixv1noZn1tGU38kQH5A5nmmbk/O45xdyBhD1esk47nKrY0mvQpZIhRjQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "a-sync-waterfall": "^1.0.0", + "asap": "^2.0.3", + "commander": "^5.1.0" + }, + "bin": { + "nunjucks-precompile": "bin/precompile" + }, + "engines": { + "node": ">= 6.9.0" + }, + "peerDependencies": { + "chokidar": "^3.3.0" + }, + "peerDependenciesMeta": { + "chokidar": { + "optional": true + } + } + }, + "node_modules/nunjucks/node_modules/commander": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/commander/-/commander-5.1.0.tgz", + "integrity": "sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 6" + } + }, + "node_modules/oas-kit-common": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/oas-kit-common/-/oas-kit-common-1.0.8.tgz", + "integrity": "sha512-pJTS2+T0oGIwgjGpw7sIRU8RQMcUoKCDWFLdBqKB2BNmGpbBMH2sdqAaOXUg8OzonZHU0L7vfJu1mJFEiYDWOQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "fast-safe-stringify": "^2.0.7" + } + }, + "node_modules/oas-linter": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/oas-linter/-/oas-linter-3.2.2.tgz", + "integrity": "sha512-KEGjPDVoU5K6swgo9hJVA/qYGlwfbFx+Kg2QB/kd7rzV5N8N5Mg6PlsoCMohVnQmo+pzJap/F610qTodKzecGQ==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "@exodus/schemasafe": "^1.0.0-rc.2", + "should": "^13.2.1", + "yaml": "^1.10.0" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-resolver": { + "version": "2.5.6", + "resolved": "https://registry.npmjs.org/oas-resolver/-/oas-resolver-2.5.6.tgz", + "integrity": "sha512-Yx5PWQNZomfEhPPOphFbZKi9W93CocQj18NlD2Pa4GWZzdZpSJvYwoiuurRI7m3SpcChrnO08hkuQDL3FGsVFQ==", + "dev": true, + "license": "BSD-3-Clause", + "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" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-schema-walker": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/oas-schema-walker/-/oas-schema-walker-1.1.5.tgz", + "integrity": "sha512-2yucenq1a9YPmeNExoUa9Qwrt9RFkjqaMAA1X+U7sbb0AqBeTIdMHky9SQQ6iN94bO5NW0W4TRYXerG+BdAvAQ==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/oas-validator": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/oas-validator/-/oas-validator-5.0.8.tgz", + "integrity": "sha512-cu20/HE5N5HKqVygs3dt94eYJfBi0TsZvPVXDhbXQHiEityDN+RROTleefoKRKKJ9dFAF2JBkDHgvWj0sjKGmw==", + "dev": true, + "license": "BSD-3-Clause", + "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" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/object-assign": { + "version": "4.1.1", + "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", + "integrity": "sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/object-inspect": { + "version": "1.13.4", + "resolved": "https://registry.npmjs.org/object-inspect/-/object-inspect-1.13.4.tgz", + "integrity": "sha512-W67iLl4J2EXEGTbfeHCffrjDfitvLANg0UlX3wFUUSTx92KXRFegMHUVgSqE+wvhAbi4WqjGg9czysTV2Epbew==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object-keys": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz", + "integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.assign": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.7.tgz", + "integrity": "sha512-nK28WOo+QIjBkDduTINE4JkF/UJJKyf2EJxvJKfblDpyg0Q+pkOHNTL0Qwy6NP6FhE/EnzV73BxxqcJaXY9anw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.entries": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.9.tgz", + "integrity": "sha512-8u/hfXFRBD1O0hPUjioLhoWFHRmt6tKA4/vZPyckBr18l1KE9uHrFaFaUi8MDRTpi4uak2goyPTSNJLXX2k2Hw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.4", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.fromentries": { + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/object.groupby": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/object.groupby/-/object.groupby-1.0.3.tgz", + "integrity": "sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/object.values": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.1.tgz", + "integrity": "sha512-gXah6aZrcUxjWg2zR2MwouP2eHlCBzdV4pygudehaKXSGW4v2AsRQUK+lwwXhii6KFZcunEnmSUoYp5CXibxtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.3", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dev": true, + "license": "ISC", + "dependencies": { + "wrappy": "1" + } + }, + "node_modules/openapi3-ts": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/openapi3-ts/-/openapi3-ts-2.0.2.tgz", + "integrity": "sha512-TxhYBMoqx9frXyOgnRHufjQfPXomTIHYKhSKJ6jHfj13kS8OEIhvmE8CTuQyKtjjWttAjX5DPxM1vmalEpo8Qw==", + "dev": true, + "license": "MIT", + "dependencies": { + "yaml": "^1.10.2" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/own-keys": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/own-keys/-/own-keys-1.0.1.tgz", + "integrity": "sha512-qFOyK5PjiWZd+QQIh+1jhdb9LpxTF0qs7Pm8o5QHYZ0M3vKqSqzsZaEB6oWlxZ+q2sJBMI/Ktgd2N5ZwQoRHfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "get-intrinsic": "^1.2.6", + "object-keys": "^1.1.1", + "safe-push-apply": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parent-module": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz", + "integrity": "sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "callsites": "^3.0.0" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/parse-json": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-5.2.0.tgz", + "integrity": "sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-is-absolute": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/path-is-absolute/-/path-is-absolute-1.0.1.tgz", + "integrity": "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-parse": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", + "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", + "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8.6" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/possible-typed-array-names": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.1.0.tgz", + "integrity": "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/postcss": { + "version": "8.5.14", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.14.tgz", + "integrity": "sha512-SoSL4+OSEtR99LHFZQiJLkT59C5B1amGO1NzTwj7TT1qCUgUO6hxOvzkOYxD+vMrXBM3XJIKzokoERdqQq/Zmg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.11", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/prettier": { + "version": "3.8.3", + "resolved": "https://registry.npmjs.org/prettier/-/prettier-3.8.3.tgz", + "integrity": "sha512-7igPTM53cGHMW8xWuVTydi2KO233VFiTNyF5hLJqpilHfmn8C8gPf+PS7dUT64YcXFbiMGZxS9pCSxL/Dxm/Jw==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "prettier": "bin/prettier.cjs" + }, + "engines": { + "node": ">=14" + }, + "funding": { + "url": "https://github.com/prettier/prettier?sponsor=1" + } + }, + "node_modules/prettier-plugin-organize-imports": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/prettier-plugin-organize-imports/-/prettier-plugin-organize-imports-4.3.0.tgz", + "integrity": "sha512-FxFz0qFhyBsGdIsb697f/EkvHzi5SZOhWAjxcx2dLt+Q532bAlhswcXGYB1yzjZ69kW8UoadFBw7TyNwlq96Iw==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "prettier": ">=2.0", + "typescript": ">=2.9", + "vue-tsc": "^2.1.0 || 3" + }, + "peerDependenciesMeta": { + "vue-tsc": { + "optional": true + } + } + }, + "node_modules/prettier-plugin-tailwindcss": { + "version": "0.6.14", + "resolved": "https://registry.npmjs.org/prettier-plugin-tailwindcss/-/prettier-plugin-tailwindcss-0.6.14.tgz", + "integrity": "sha512-pi2e/+ZygeIqntN+vC573BcW5Cve8zUB0SSAGxqpB4f96boZF4M3phPVoOFCeypwkpRYdi7+jQ5YJJUwrkGUAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.21.3" + }, + "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": "*" + }, + "peerDependenciesMeta": { + "@ianvs/prettier-plugin-sort-imports": { + "optional": true + }, + "@prettier/plugin-hermes": { + "optional": true + }, + "@prettier/plugin-oxc": { + "optional": true + }, + "@prettier/plugin-pug": { + "optional": true + }, + "@shopify/prettier-plugin-liquid": { + "optional": true + }, + "@trivago/prettier-plugin-sort-imports": { + "optional": true + }, + "@zackad/prettier-plugin-twig": { + "optional": true + }, + "prettier-plugin-astro": { + "optional": true + }, + "prettier-plugin-css-order": { + "optional": true + }, + "prettier-plugin-import-sort": { + "optional": true + }, + "prettier-plugin-jsdoc": { + "optional": true + }, + "prettier-plugin-marko": { + "optional": true + }, + "prettier-plugin-multiline-arrays": { + "optional": true + }, + "prettier-plugin-organize-attributes": { + "optional": true + }, + "prettier-plugin-organize-imports": { + "optional": true + }, + "prettier-plugin-sort-imports": { + "optional": true + }, + "prettier-plugin-style-order": { + "optional": true + }, + "prettier-plugin-svelte": { + "optional": true + } + } + }, + "node_modules/prop-types": { + "version": "15.8.1", + "resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz", + "integrity": "sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.4.0", + "object-assign": "^4.1.1", + "react-is": "^16.13.1" + } + }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/queue-microtask": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/queue-microtask/-/queue-microtask-1.2.3.tgz", + "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, + "node_modules/react": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.5.tgz", + "integrity": "sha512-llUJLzz1zTUBrskt2pwZgLq59AemifIftw4aB7JxOqf1HY2FDaGDxgwpAPVzHU1kdWabH7FauP4i1oEeer2WCA==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-day-picker": { + "version": "9.14.0", + "resolved": "https://registry.npmjs.org/react-day-picker/-/react-day-picker-9.14.0.tgz", + "integrity": "sha512-tBaoDWjPwe0M5pGrum4H0SR6Lyk+BO9oHnp9JbKpGKW2mlraNPgP9BMfsg5pWpwrssARmeqk7YBl2oXutZTaHA==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=18" + }, + "funding": { + "type": "individual", + "url": "https://github.com/sponsors/gpbl" + }, + "peerDependencies": { + "react": ">=16.8.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.5", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.5.tgz", + "integrity": "sha512-J5bAZz+DXMMwW/wV3xzKke59Af6CHY7G4uYLN1OvBcKEsWOs4pQExj86BBKamxl/Ik5bx9whOrvBlSDfWzgSag==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.5" + } + }, + "node_modules/react-hook-form": { + "version": "7.75.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.75.0.tgz", + "integrity": "sha512-Ovv94H+0p3sJ7B9B5QxPuCP1u8V/cHuVGyH55cSwodYDtoJwK+fqk3vjfIgSX59I2U/bU4z0nRJ9HMLpNiWEmw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "16.13.1", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz", + "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/react-medium-image-zoom": { + "version": "5.4.5", + "resolved": "https://registry.npmjs.org/react-medium-image-zoom/-/react-medium-image-zoom-5.4.5.tgz", + "integrity": "sha512-58QSIRK6X3uw2fSTejJRnH0JuKTZl7ZJYX+sAMaYx4YTEm33gsNdnP5RuQSCnBiAvisQeErqZWAT31bR89WB6g==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/rpearce" + } + ], + "license": "BSD-3-Clause", + "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" + } + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "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" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/reflect.getprototypeof": { + "version": "1.0.10", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.10.tgz", + "integrity": "sha512-00o4I+DVrefhv+nX0ulyi3biSHCPDe+yLv5o/p6d/UVlirijB8E16FtfwSAi4g3tcqrQ4lRAqQSoFEZJehYEcw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/reftools": { + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/reftools/-/reftools-1.1.9.tgz", + "integrity": "sha512-OVede/NQE13xBQ+ob5CKd5KyeJYU2YInb1bmV4nRoOfquZPkAkxuOXicSe1PvqIuZZ4kD13sPKBbR7UFDmli6w==", + "dev": true, + "license": "BSD-3-Clause", + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/regexp.prototype.flags": { + "version": "1.5.4", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", + "integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/require-directory": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/require-directory/-/require-directory-2.1.1.tgz", + "integrity": "sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/reserved-words": { + "version": "0.1.2", + "resolved": "https://registry.npmjs.org/reserved-words/-/reserved-words-0.1.2.tgz", + "integrity": "sha512-0S5SrIUJ9LfpbVl4Yzij6VipUdafHrOTzvmfazSw/jeZrZtQK303OPZW+obtkaw7jQlTQppy0UvZWm9872PbRw==", + "dev": true, + "license": "MIT" + }, + "node_modules/resolve": { + "version": "2.0.0-next.6", + "resolved": "https://registry.npmjs.org/resolve/-/resolve-2.0.0-next.6.tgz", + "integrity": "sha512-3JmVl5hMGtJ3kMmB3zi3DL25KfkCEyy3Tw7Gmw7z5w8M9WlwoPFnIvwChzu1+cF3iaK3sp18hhPz8ANeimdJfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "is-core-module": "^2.16.1", + "node-exports-info": "^1.6.0", + "object-keys": "^1.1.1", + "path-parse": "^1.0.7", + "supports-preserve-symlinks-flag": "^1.0.0" + }, + "bin": { + "resolve": "bin/resolve" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/resolve-from": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", + "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/resolve-pkg-maps": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/resolve-pkg-maps/-/resolve-pkg-maps-1.0.0.tgz", + "integrity": "sha512-seS2Tj26TBVOC2NIc2rOe2y2ZO7efxITtLZcGSOnHHNOQ7CkiUBfw0Iw2ck6xkIhPwLhKNLS8BO+hEpngQlqzw==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/privatenumber/resolve-pkg-maps?sponsor=1" + } + }, + "node_modules/reusify": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/reusify/-/reusify-1.1.0.tgz", + "integrity": "sha512-g6QUff04oZpHs0eG5p83rFLhHeV00ug/Yf9nZM6fLeUrPguBTkTQOdpAWWspMh55TZfVQDPaN3NQJfbVRAxdIw==", + "dev": true, + "license": "MIT", + "engines": { + "iojs": ">=1.0.0", + "node": ">=0.10.0" + } + }, + "node_modules/rimraf": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", + "dev": true, + "license": "ISC", + "dependencies": { + "glob": "^7.1.3" + }, + "bin": { + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/run-parallel": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", + "integrity": "sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "queue-microtask": "^1.2.2" + } + }, + "node_modules/safe-array-concat": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.4.tgz", + "integrity": "sha512-wtZlHyOje6OZTGqAoaDKxFkgRtkF9CnHAVnCHKfuj200wAgL+bSJhdsCD2l0Qx/2ekEXjPWcyKkfGb5CPboslg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.9", + "call-bound": "^1.0.4", + "get-intrinsic": "^1.3.0", + "has-symbols": "^1.1.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">=0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-push-apply": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/safe-push-apply/-/safe-push-apply-1.0.0.tgz", + "integrity": "sha512-iKE9w/Z7xCzUMIZqdBsp6pEQvwuEebH4vdpjcDWnyzaI6yl6O9FHvVpmGelvEHNsoY6wGblkxR6Zty/h00WiSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "isarray": "^2.0.5" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/safe-regex-test": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.1.0.tgz", + "integrity": "sha512-x/+Cz4YrimQxQccJf5mKEbIa1NzeCRNI5Ecl/ekmlYaampdNLPalVyIcCZNNH3MvmqBugV5TMYZXv0ljslUlaw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "is-regex": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-function-length": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz", + "integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-function-name": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", + "functions-have-names": "^1.2.3", + "has-property-descriptors": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/set-proto": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/set-proto/-/set-proto-1.0.0.tgz", + "integrity": "sha512-RJRdvCo6IAnPdsvP/7m6bsQqNnn1FCBX5ZNtFL98MmFF/4xAIJTIg1YbHW5DC2W5SKZanrC6i4HsJqlajw/dZw==", + "dev": true, + "license": "MIT", + "dependencies": { + "dunder-proto": "^1.0.1", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/sharp": { + "version": "0.34.5", + "resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.5.tgz", + "integrity": "sha512-Ou9I5Ft9WNcCbXrU9cMgPBcCK8LiwLqcbywW3t4oDV37n1pzpuNLsYiAV8eODnjbtQlSDwZ2cUEeQz4E54Hltg==", + "hasInstallScript": true, + "license": "Apache-2.0", + "optional": true, + "dependencies": { + "@img/colour": "^1.0.0", + "detect-libc": "^2.1.2", + "semver": "^7.7.3" + }, + "engines": { + "node": "^18.17.0 || ^20.3.0 || >=21.0.0" + }, + "funding": { + "url": "https://opencollective.com/libvips" + }, + "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" + } + }, + "node_modules/sharp/node_modules/semver": { + "version": "7.8.0", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.0.tgz", + "integrity": "sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==", + "license": "ISC", + "optional": true, + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/should": { + "version": "13.2.3", + "resolved": "https://registry.npmjs.org/should/-/should-13.2.3.tgz", + "integrity": "sha512-ggLesLtu2xp+ZxI+ysJTmNjh2U0TsC+rQ/pfED9bUZZ4DKefP27D+7YJVVTvKsmjLpIi9jAa7itwDGkDDmt1GQ==", + "dev": true, + "license": "MIT", + "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" + } + }, + "node_modules/should-equal": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/should-equal/-/should-equal-2.0.0.tgz", + "integrity": "sha512-ZP36TMrK9euEuWQYBig9W55WPC7uo37qzAEmbjHz4gfyuXrEUgF8cUvQVO+w+d3OMfPvSRQJ22lSm8MQJ43LTA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.4.0" + } + }, + "node_modules/should-format": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/should-format/-/should-format-3.0.3.tgz", + "integrity": "sha512-hZ58adtulAk0gKtua7QxevgUaXTTXxIi8t41L3zo9AHvjXO1/7sdLECuHeIN2SRtYXpNkmhoUP2pdeWgricQ+Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-type-adaptors": "^1.0.1" + } + }, + "node_modules/should-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/should-type/-/should-type-1.4.0.tgz", + "integrity": "sha512-MdAsTu3n25yDbIe1NeN69G4n6mUnJGtSJHygX3+oN0ZbO3DTiATnf7XnYJdGT42JCXurTb1JI0qOBR65shvhPQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/should-type-adaptors": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/should-type-adaptors/-/should-type-adaptors-1.1.0.tgz", + "integrity": "sha512-JA4hdoLnN+kebEp2Vs8eBe9g7uy0zbRo+RMcU0EsNy+R+k049Ki+N5tT5Jagst2g7EAja+euFuoXFCa8vIklfA==", + "dev": true, + "license": "MIT", + "dependencies": { + "should-type": "^1.3.0", + "should-util": "^1.0.0" + } + }, + "node_modules/should-util": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/should-util/-/should-util-1.0.1.tgz", + "integrity": "sha512-oXF8tfxx5cDk8r2kYqlkUJzZpDBqVY/II2WhvU0n9Y3XYvAYRmeaf1PvvIvTgPnv4KJ+ES5M0PyDq5Jp+Ygy2g==", + "dev": true, + "license": "MIT" + }, + "node_modules/side-channel": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/side-channel/-/side-channel-1.1.0.tgz", + "integrity": "sha512-ZX99e6tRweoUXqR+VBrslhda51Nh5MTQwou5tnUDgbtyM0dBgmhEDtWGP/xbKn6hqfPRHujUNwz5fy/wbbhnpw==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-list": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-list/-/side-channel-list-1.0.1.tgz", + "integrity": "sha512-mjn/0bi/oUURjc5Xl7IaWi/OJJJumuoJFQJfDDyO46+hBWsfaVM65TBHq2eoZBhzl9EchxOijpkbRC8SVBQU0w==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "object-inspect": "^1.13.4" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-map": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/side-channel-map/-/side-channel-map-1.0.1.tgz", + "integrity": "sha512-VCjCNfgMsby3tTdo02nbjtM/ewra6jPHmpThenkTYh8pG9ucZ/1P8So4u4FGBek/BjpOVsDCMoLA/iuBKIFXRA==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.2", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.5", + "object-inspect": "^1.13.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/side-channel-weakmap": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/side-channel-weakmap/-/side-channel-weakmap-1.0.2.tgz", + "integrity": "sha512-WPS/HvHQTYnHisLo9McqBHOJk2FkHO/tlpvldyrnem4aeQp4hai3gythswg6p01oSoTl58rcpiFAjF2br2Ak2A==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/sonner": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/sonner/-/sonner-2.0.7.tgz", + "integrity": "sha512-W6ZN4p58k8aDKA4XPcx2hpIQXBRAgyiWVkYhT7CvK6D3iAu7xjvVyhQHg2/iaKJZ1XVJ4r7XuwGL+WGEK37i9w==", + "license": "MIT", + "peerDependencies": { + "react": "^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stable-hash": { + "version": "0.0.5", + "resolved": "https://registry.npmjs.org/stable-hash/-/stable-hash-0.0.5.tgz", + "integrity": "sha512-+L3ccpzibovGXFK+Ap/f8LOS0ahMrHTf3xu7mMLSpEGU0EO9ucaysSylKo9eRDFNhWve/y275iPmIZ4z39a9iA==", + "dev": true, + "license": "MIT" + }, + "node_modules/stop-iteration-iterator": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/stop-iteration-iterator/-/stop-iteration-iterator-1.1.0.tgz", + "integrity": "sha512-eLoXW/DHyl62zxY4SCaIgnRhuMr6ri4juEYARS8E6sCEqzKpOiE521Ucofdx+KnDZl5xmvGYaaKCk5FEOxJCoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "es-errors": "^1.3.0", + "internal-slot": "^1.1.0" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dev": true, + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/string-width/node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "dev": true, + "license": "MIT" + }, + "node_modules/string.prototype.includes": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/string.prototype.includes/-/string.prototype.includes-2.0.1.tgz", + "integrity": "sha512-o7+c9bW6zpAdJHTtujeePODAhkuicdAryFsfVKwA+wGw89wJ4GTY484WTucM9hLtDEOpOvI+aHnzqnC5lHp4Rg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.12", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", + "integrity": "sha512-6CC9uyBL+/48dYizRf7H7VAYCMCNTBeM78x/VTUe9bFEaxBepPJDa1Ow99LqI/1yF7kuy7Q3cQsYMrcjGUcskA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", + "dev": true, + "license": "MIT", + "dependencies": { + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" + } + }, + "node_modules/string.prototype.trim": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.10.tgz", + "integrity": "sha512-Rs66F0P/1kedk5lyYyH9uBzuiI/kNRmwJAR9quK6VOtIpZ2G+hMZd+HQbbv25MgCA6gEffoMZYxlTod4WcdrKA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimend": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.9.tgz", + "integrity": "sha512-G7Ok5C6E/j4SGfyLCloXTrngQIQU3PWtXGst3yM7Bea9FRURf1S42ZHlZZtsNque2FN2PoUhfZXYLNWwEr4dLQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.8", + "call-bound": "^1.0.2", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/string.prototype.trimstart": { + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/strip-ansi": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-regex": "^5.0.1" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/strip-bom": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-bom/-/strip-bom-3.0.0.tgz", + "integrity": "sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/strip-json-comments": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", + "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/styled-jsx": { + "version": "5.1.6", + "resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz", + "integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==", + "license": "MIT", + "dependencies": { + "client-only": "0.0.1" + }, + "engines": { + "node": ">= 12.0.0" + }, + "peerDependencies": { + "react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0" + }, + "peerDependenciesMeta": { + "@babel/core": { + "optional": true + }, + "babel-plugin-macros": { + "optional": true + } + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-preserve-symlinks-flag": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", + "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/swagger2openapi": { + "version": "7.0.8", + "resolved": "https://registry.npmjs.org/swagger2openapi/-/swagger2openapi-7.0.8.tgz", + "integrity": "sha512-upi/0ZGkYgEcLeGieoz8gT74oWHA0E7JivX7aN9mAf+Tc7BQoRBvnIGHoPDw+f9TXTW4s6kGYCZJtauP6OYp7g==", + "dev": true, + "license": "BSD-3-Clause", + "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" + }, + "funding": { + "url": "https://github.com/Mermade/oas-kit?sponsor=1" + } + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.0.tgz", + "integrity": "sha512-y6nxMGB1nMW9R6k96e5gdIFzcfL/gTJRNaqGes1YvkLnPVXzWgbqFF2yLC0T8G774n24cx3Pe8XrKoniCOAH+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/timers-ext": { + "version": "0.1.8", + "resolved": "https://registry.npmjs.org/timers-ext/-/timers-ext-0.1.8.tgz", + "integrity": "sha512-wFH7+SEAcKfJpfLPkrgMPvvwnEtj8W4IurvEyrKsDleXnKLCDw71w8jltvfLa8Rm4qQxxT4jmDBYbJG/z7qoww==", + "dev": true, + "license": "ISC", + "dependencies": { + "es5-ext": "^0.10.64", + "next-tick": "^1.1.0" + }, + "engines": { + "node": ">=0.12" + } + }, + "node_modules/tiny-pinyin": { + "version": "1.3.2", + "resolved": "https://registry.npmjs.org/tiny-pinyin/-/tiny-pinyin-1.3.2.tgz", + "integrity": "sha512-uHNGu4evFt/8eNLldazeAM1M8JrMc1jshhJJfVRARTN3yT8HEEibofeQ7QETWQ5ISBjd6fKtTVBCC/+mGS6FpA==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyglobby": { + "version": "0.2.16", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", + "integrity": "sha512-pn99VhoACYR8nFHhxqix+uvsbXineAasWm5ojXoN8xEwK5Kd3/TrhNn1wByuD52UxWRLy8pu+kRMniEi6Eq9Zg==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyglobby/node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/tinyglobby/node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/to-regex-range": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", + "integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-number": "^7.0.0" + }, + "engines": { + "node": ">=8.0" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "dev": true, + "license": "MIT" + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tsconfig-paths": { + "version": "3.15.0", + "resolved": "https://registry.npmjs.org/tsconfig-paths/-/tsconfig-paths-3.15.0.tgz", + "integrity": "sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/json5": "^0.0.29", + "json5": "^1.0.2", + "minimist": "^1.2.6", + "strip-bom": "^3.0.0" + } + }, + "node_modules/tsconfig-paths/node_modules/json5": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz", + "integrity": "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==", + "dev": true, + "license": "MIT", + "dependencies": { + "minimist": "^1.2.0" + }, + "bin": { + "json5": "lib/cli.js" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/tw-animate-css": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/tw-animate-css/-/tw-animate-css-1.4.0.tgz", + "integrity": "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Wombosvideo" + } + }, + "node_modules/type": { + "version": "2.7.3", + "resolved": "https://registry.npmjs.org/type/-/type-2.7.3.tgz", + "integrity": "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typed-array-buffer": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.3.tgz", + "integrity": "sha512-nAYYwfY3qnzX30IkA6AQZjVbtK6duGontcQm1WSG1MD94YLqK0515GNApXkoxKOWMusVssAHWLh9SeaoefYFGw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.14" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/typed-array-byte-length": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.3.tgz", + "integrity": "sha512-BaXgOuIxz8n8pIq3e7Atg/7s+DpiYrxn4vdot3w9KbnBhcRQq6o3xemQdIfynqSeXeDrF32x+WvfzmOjPiY9lg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-byte-offset": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.4.tgz", + "integrity": "sha512-bTlAFB/FBYMcuX81gbL4OcpH5PmlFHqlCCpAl8AlEzMz5k53oNDvN8p1PNOWLEmI2x4orp3raOFB51tv9X+MFQ==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typed-array-length": { + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.7.tgz", + "integrity": "sha512-3KS2b+kL7fsuk/eJZ7EQdnEmQoaho/r6KUef7hxvltNA5DR8NAUM+8wJMbJyZ4G9/7i3v5zPBIMN5aybAh2/Jg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.59.2", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.59.2.tgz", + "integrity": "sha512-pJw051uomb3ZeCzGTpRb8RbEqB5Y4WWet8gl/GcTlU35BSx0PVdZ86/bqkQCyKKuraVQEK7r6kBHQXF+fBhkoQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.59.2", + "@typescript-eslint/parser": "8.59.2", + "@typescript-eslint/typescript-estree": "8.59.2", + "@typescript-eslint/utils": "8.59.2" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/unbox-primitive": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.1.0.tgz", + "integrity": "sha512-nWJ91DjeOkej/TA8pXQ3myruKpKEYgqvpw9lz4OPHj/NWFNluYrjbz9j01CJ8yKQd2g4jFoOkINCTW2I5LEEyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "call-bound": "^1.0.3", + "has-bigints": "^1.0.2", + "has-symbols": "^1.1.0", + "which-boxed-primitive": "^1.1.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/undici-types": { + "version": "6.21.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-6.21.0.tgz", + "integrity": "sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/unrs-resolver": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/unrs-resolver/-/unrs-resolver-1.11.1.tgz", + "integrity": "sha512-bSjt9pjaEBnNiGgc9rUiHGKv5l4/TGzDmYw3RhnkJGtLhbnnA/5qJj7x3dNDCRx/PJxu774LlH8lCOlB4hEfKg==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "napi-postinstall": "^0.3.0" + }, + "funding": { + "url": "https://opencollective.com/unrs-resolver" + }, + "optionalDependencies": { + "@unrs/resolver-binding-android-arm-eabi": "1.11.1", + "@unrs/resolver-binding-android-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-arm64": "1.11.1", + "@unrs/resolver-binding-darwin-x64": "1.11.1", + "@unrs/resolver-binding-freebsd-x64": "1.11.1", + "@unrs/resolver-binding-linux-arm-gnueabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm-musleabihf": "1.11.1", + "@unrs/resolver-binding-linux-arm64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-arm64-musl": "1.11.1", + "@unrs/resolver-binding-linux-ppc64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-riscv64-musl": "1.11.1", + "@unrs/resolver-binding-linux-s390x-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-gnu": "1.11.1", + "@unrs/resolver-binding-linux-x64-musl": "1.11.1", + "@unrs/resolver-binding-wasm32-wasi": "1.11.1", + "@unrs/resolver-binding-win32-arm64-msvc": "1.11.1", + "@unrs/resolver-binding-win32-ia32-msvc": "1.11.1", + "@unrs/resolver-binding-win32-x64-msvc": "1.11.1" + } + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "dev": true, + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dev": true, + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/which-boxed-primitive": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/which-boxed-primitive/-/which-boxed-primitive-1.1.1.tgz", + "integrity": "sha512-TbX3mj8n0odCBFVlY8AxkqcHASw3L60jIuF8jFP78az3C2YhmGvqbHBpAjTRH2/xqYunrJ9g1jSyjCjpoWzIAA==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-builtin-type": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.2.1.tgz", + "integrity": "sha512-6iBczoX+kDQ7a3+YJBnh3T+KZRxM/iYNPXicqk66/Qfm1b93iu+yOImkg0zHbj5LNOcNv1TEADiZ0xa34B4q6Q==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-collection": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/which-typed-array": { + "version": "1.1.20", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.20.tgz", + "integrity": "sha512-LYfpUkmqwl0h9A2HL09Mms427Q1RZWuOHsukfVcKRq9q95iQxdw0ix1JQrqbcDR9PH1QDwf5Qo8OZb5lksZ8Xg==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/wrap-ansi": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", + "integrity": "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "ansi-styles": "^4.0.0", + "string-width": "^4.1.0", + "strip-ansi": "^6.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/wrap-ansi?sponsor=1" + } + }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/y18n": { + "version": "5.0.8", + "resolved": "https://registry.npmjs.org/y18n/-/y18n-5.0.8.tgz", + "integrity": "sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=10" + } + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yaml": { + "version": "1.10.3", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-1.10.3.tgz", + "integrity": "sha512-vIYeF1u3CjlhAFekPPAk2h/Kv4T3mAkMox5OymRiJQB0spDP10LHvt+K7G9Ny6NuuMAb25/6n1qyUjAcGNf/AA==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">= 6" + } + }, + "node_modules/yargs": { + "version": "17.7.2", + "resolved": "https://registry.npmjs.org/yargs/-/yargs-17.7.2.tgz", + "integrity": "sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==", + "dev": true, + "license": "MIT", + "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" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/yargs-parser": { + "version": "21.1.1", + "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-21.1.1.tgz", + "integrity": "sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==", + "dev": true, + "license": "ISC", + "engines": { + "node": ">=12" + } + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.13", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.13.tgz", + "integrity": "sha512-efI2tVaVQPqtOh114loML/Z80Y4NP3yc+Ff0fYiZJPauNeWZeIp/bRFD7I9bfmCOYBh/PHxlglQ9+wvlwnPikQ==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/web/package.json b/web/package.json index 9e521c9a..fab05ed1 100644 --- a/web/package.json +++ b/web/package.json @@ -30,7 +30,7 @@ "react-medium-image-zoom": "^5.3.0", "sonner": "^2.0.6", "tailwind-merge": "^3.3.1", - "zustand": "^5.0.8" + "zustand": "^5.0.13" }, "devDependencies": { "@eslint/eslintrc": "^3", diff --git a/web/src/app/accounts/components/account-import-dialog.tsx b/web/src/app/accounts/components/account-import-dialog.tsx index 2ee74edb..d5acbcd7 100644 --- a/web/src/app/accounts/components/account-import-dialog.tsx +++ b/web/src/app/accounts/components/account-import-dialog.tsx @@ -26,10 +26,10 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { Textarea } from "@/components/ui/textarea"; -import { createAccounts, type Account } from "@/lib/api"; +import { createAccounts, createOAuthAccounts, type Account } from "@/lib/api"; import { cn } from "@/lib/utils"; -type ImportMethod = "menu" | "token" | "session" | "cpa"; +type ImportMethod = "menu" | "token" | "session" | "cpa" | "oauth" | "oauth_flow"; type AccountImportDialogProps = { disabled?: boolean; @@ -65,7 +65,7 @@ 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.onerror = () => reject(reader.error ?? new Error(`Đọc tệp thất bại: ${file.name}`)); reader.readAsText(file); }); } @@ -85,7 +85,7 @@ function MethodCard({ - 当前识别 {tokenCount} 个 Token + Đã nhận diện {tokenCount} Token
- +