diff --git a/README.md b/README.md index bc6e939..1a4b7aa 100644 --- a/README.md +++ b/README.md @@ -1,176 +1,164 @@ # adobe2api ---- - -### ✨ 广告时间 (o゜▽゜)o☆ - -这是我个人独立搭建和长期维护的网站:[**Pixelle Labs**](https://www.pixellelabs.com/) - -主要分享我正在开发的 **AI 创意工具**、图像/视频相关小产品和各种有趣实验。欢迎大家来逛逛、免费体验、随便玩耍 (๑•̀ㅂ•́)و✧;如果你有想法或需求,也非常欢迎反馈交流!ヾ(≧▽≦*)o - ---- - -Adobe Firefly / OpenAI 兼容网关服务。 - +Adobe Firefly / OpenAI 兼容网关服务。 English README: `README_EN.md` - 当前设计: - - 对外统一入口:`/v1/chat/completions`(图像 + 视频) -- 可选图像专用接口:`/v1/images/generations` -- Token 池管理(手动 Token + 自动刷新 Token) -- 管理后台 Web UI:Token / 配置 / 日志 / 刷新配置导入 +- 图像专用入口:`/v1/images/generations` +- 支持多账号 Token 池、自动刷新、管理后台、请求日志与任务进度查询 -## 1)部署方式 +## 1. 部署方式 -### A. 本地开发/运行 - -1. **安装依赖**: +### A. 本地运行 ```bash pip install -r requirements.txt -``` - -2. **启动服务**(在 `adobe2api/` 目录下执行): - -```bash uvicorn app:app --host 0.0.0.0 --port 6001 --reload ``` -3. **访问管理后台**: - +管理后台: - 地址:`http://127.0.0.1:6001/` - 默认账号密码:`admin / admin` -- 登录后可在「系统配置」修改,或编辑 `config/config.json` - -### B. Docker 部署 (推荐) -本项目已提供 Docker 支持,推荐使用 Docker Compose 一键启动: +### B. Docker ```bash docker compose up -d --build ``` -## 2)服务鉴权 +## 2. 服务鉴权 服务 API Key 配置在 `config/config.json` 的 `api_key` 字段。 -- 若已设置,调用时可使用以下任一方式: - - `Authorization: Bearer ` - - `X-API-Key: ` +调用时可使用: +- `Authorization: Bearer ` +- `X-API-Key: ` 管理后台和管理 API 需要先通过 `/api/v1/auth/login` 登录并持有会话 Cookie。 -## 3)外部 API 使用 - -### 3.0 支持的模型族 - -当前支持如下模型族: - -- `firefly-nano-banana-*`(图像,对应上游 `nano-banana-2`) -- `firefly-nano-banana2-*`(图像,对应上游 `nano-banana-3`) -- `firefly-nano-banana-pro-*`(图像) -- `firefly-sora2-*`(视频) -- `firefly-sora2-pro-*`(视频) -- `firefly-veo31-*`(视频) -- `firefly-veo31-ref-*`(视频,参考图模式) -- `firefly-veo31-fast-*`(视频) - -Nano Banana 图像模型(`nano-banana-2`): - -- 命名:`firefly-nano-banana-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` -- 示例: - - `firefly-nano-banana-2k-16x9` - - `firefly-nano-banana-4k-1x1` - -Nano Banana 2 图像模型(`nano-banana-3`): +## 3. 外部 API 使用 -- 命名:`firefly-nano-banana2-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` -- 示例: - - `firefly-nano-banana2-2k-16x9` - - `firefly-nano-banana2-4k-1x1` +### 3.0 支持的模型 -Nano Banana Pro 图像模型(兼容旧命名): - -- 命名:`firefly-nano-banana-pro-{resolution}-{ratio}` -- 分辨率:`1k` / `2k` / `4k` -- 比例后缀:`1x1` / `16x9` / `9x16` / `4x3` / `3x4` -- 示例: - - `firefly-nano-banana-pro-2k-16x9` - - `firefly-nano-banana-pro-4k-1x1` +当前公开模型如下: -Sora2 视频模型: +- `nano-banana`(图像,对应上游 `nano-banana-2`) +- `nano-banana2`(图像,对应上游 `nano-banana-3`) +- `nano-banana-pro`(图像) +- `sora2`(视频) +- `sora2-pro`(视频) +- `veo31`(视频) +- `veo31-ref`(视频,参考图模式) +- `veo31-fast`(视频) -- 命名:`firefly-sora2-{duration}-{ratio}` -- 时长:`4s` / `8s` / `12s` -- 比例:`9x16` / `16x9` -- 示例: - - `firefly-sora2-4s-16x9` - - `firefly-sora2-8s-9x16` +说明: +- `nano-banana`、`nano-banana2`、`nano-banana-pro` 现在都统一通过 `output_resolution` 选择 `1K` / `2K` / `4K` +- 旧的 `nano-banana-4k`、`nano-banana2-4k`、`nano-banana-pro-4k` 仍保留兼容,但不会继续在 `/v1/models` 中单独展示 +- 视频模型继续通过请求参数单独传 `duration`、`aspect_ratio`、`resolution`、`reference_mode` -Sora2 Pro 视频模型: +### 3.1 Banana 图像模型 -- 命名:`firefly-sora2-pro-{duration}-{ratio}` -- 时长:`4s` / `8s` / `12s` -- 比例:`9x16` / `16x9` +Nano Banana(`nano-banana-2`): +- 命名:`model=nano-banana` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - - `firefly-sora2-pro-4s-16x9` - - `firefly-sora2-pro-8s-9x16` - -Veo31 视频模型: - -- 命名:`firefly-veo31-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` -- 最多支持 2 张参考图: - - 1 张:首帧参考 - - 2 张:首帧 + 尾帧参考 -- 音频默认开启 + - `model=nano-banana, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana, output_resolution=4K, aspect_ratio=16:9` + +Nano Banana 2(`nano-banana-3`): +- 命名:`model=nano-banana2` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - - `firefly-veo31-4s-16x9-1080p` - - `firefly-veo31-6s-9x16-720p` - -Veo31 Ref 视频模型(参考图模式): - -- 命名:`firefly-veo31-ref-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` -- 始终使用参考图模式(不是首尾帧模式) -- 最多支持 3 张参考图(映射到上游 `referenceBlobs[].usage="asset"`) + - `model=nano-banana2, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana2, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana2, output_resolution=4K, aspect_ratio=16:9` + +Nano Banana Pro: +- 命名:`model=nano-banana-pro` +- 分辨率:`output_resolution=1K / 2K / 4K` +- 比例:`aspect_ratio=1:1 / 16:9 / 9:16 / 4:3 / 3:4` - 示例: - - `firefly-veo31-ref-4s-9x16-720p` - - `firefly-veo31-ref-6s-16x9-1080p` - - `firefly-veo31-ref-8s-9x16-1080p` - -Veo31 Fast 视频模型: - -- 命名:`firefly-veo31-fast-{duration}-{ratio}-{resolution}` -- 时长:`4s` / `6s` / `8s` -- 比例:`16x9` / `9x16` -- 分辨率:`1080p` / `720p` -- 最多支持 2 张参考图: - - 1 张:首帧参考 - - 2 张:首帧 + 尾帧参考 -- 音频默认开启 -- 示例: - - `firefly-veo31-fast-4s-16x9-1080p` - - `firefly-veo31-fast-6s-9x16-720p` - -### 3.1 获取模型列表 + - `model=nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` + - `model=nano-banana-pro, output_resolution=1K, aspect_ratio=1:1` + - `model=nano-banana-pro, output_resolution=4K, aspect_ratio=16:9` + +### 3.2 Banana 图像尺寸映射规则 + +这类模型最终不会直接使用你传入的像素宽高,而是根据 `output_resolution + aspect_ratio` 自动换算成固定尺寸。 +如果没有传 `aspect_ratio`,但传了 `size`,服务会先根据 `size` 自动反推比例,再套用下表。 + +`1K` +- `1:1` -> `1024 x 1024` +- `16:9` -> `1360 x 768` +- `9:16` -> `768 x 1360` +- `4:3` -> `1152 x 864` +- `3:4` -> `864 x 1152` + +`2K` +- `1:1` -> `2048 x 2048` +- `16:9` -> `2752 x 1536` +- `9:16` -> `1536 x 2752` +- `4:3` -> `2048 x 1536` +- `3:4` -> `1536 x 2048` + +`4K` +- `1:1` -> `4096 x 4096` +- `16:9` -> `5504 x 3072` +- `9:16` -> `3072 x 5504` +- `4:3` -> `4096 x 3072` +- `3:4` -> `3072 x 4096` + +### 3.3 视频模型 + +Sora2: +- 命名:`model=sora2` +- 时长:`duration=4 / 8 / 12` +- 比例:`aspect_ratio=16:9 / 9:16` + +Sora2 Pro: +- 命名:`model=sora2-pro` +- 时长:`duration=4 / 8 / 12` +- 比例:`aspect_ratio=16:9 / 9:16` + +Veo31: +- 命名:`model=veo31` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` +- 参考模式:`reference_mode=frame / image` + +Veo31 Ref: +- 命名:`model=veo31-ref` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` +- 固定参考图模式:`reference_mode=image` + +Veo31 Fast: +- 命名:`model=veo31-fast` +- 时长:`duration=4 / 6 / 8` +- 比例:`aspect_ratio=16:9 / 9:16` +- 分辨率:`resolution=720p / 1080p` + +Veo31 单图/多图语义: +- `veo31` / `veo31-fast` 且 `reference_mode=frame`:帧模式 +- 1 张图:首帧 +- 2 张图:首帧 + 尾帧 +- `veo31-ref`,或 `veo31` 且 `reference_mode=image`:参考图模式 +- 1~3 张图:参考图 + +### 3.4 获取模型列表 ```bash curl -X GET "http://127.0.0.1:6001/v1/models" \ -H "Authorization: Bearer " ``` -### 3.2 统一入口:`/v1/chat/completions` +### 3.5 统一入口:`/v1/chat/completions` 文生图: @@ -179,19 +167,23 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a cinematic mountain sunrise"}] }' ``` -图生图(在最新 user 消息中传入图片): +图生图: ```bash curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "nano-banana-pro", + "output_resolution": "4K", + "aspect_ratio": "16:9", "messages": [{ "role":"user", "content":[ @@ -209,19 +201,13 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-4s-16x9", + "model": "sora2", + "duration": 4, + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a drone shot over snowy forest"}] }' ``` -Veo31 单图语义说明: - -- `firefly-veo31-*` / `firefly-veo31-fast-*`:帧模式 - - 1 张图 => 首帧 - - 2 张图 => 首帧 + 尾帧 -- `firefly-veo31-ref-*`:参考图模式 - - 1~3 张图 => 参考图 - 图生视频: ```bash @@ -229,7 +215,11 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-8s-9x16", + "model": "veo31", + "duration": 6, + "aspect_ratio": "9:16", + "resolution": "720p", + "reference_mode": "image", "messages": [{ "role":"user", "content":[ @@ -240,65 +230,44 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ }' ``` -### 3.3 图像接口:`/v1/images/generations` +### 3.6 图像接口:`/v1/images/generations` ```bash curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-4k-16x9", + "model": "nano-banana-pro", + "output_resolution": "4K", + "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' ``` -## 4)Cookie 导入 - -### 第一步:使用浏览器插件导出(推荐) - -本项目提供了一个配套的浏览器插件,可以方便地从 Adobe Firefly 页面导出所需的 Cookie 数据。 - -- 插件源码位置:`browser-cookie-exporter/` -- 可导出最简 `cookie_*.json`(仅包含 `cookie` 字段) -- 详细说明见:`browser-cookie-exporter/README.md` +## 4. Cookie 导入 -**插件安装与使用步骤:** +项目自带浏览器插件目录:`browser-cookie-exporter/` -1. 打开 Chrome 或 Edge 浏览器的扩展管理页:`chrome://extensions` -2. 开启右上角的「开发者模式」 -3. 点击「加载已解压的扩展程序」,选择项目中的 `browser-cookie-exporter/` 目录 -4. 在浏览器中正常登录 [Adobe Firefly](https://firefly.adobe.com/) -5. 点击浏览器工具栏的插件图标,选择导出范围 -6. 点击「导出最简 JSON」并保存文件 +推荐流程: +1. 在 Chrome / Edge 打开 `chrome://extensions` +2. 开启开发者模式 +3. 加载 `browser-cookie-exporter/` +4. 登录 [Adobe Firefly](https://firefly.adobe.com/) +5. 用插件导出 Cookie JSON +6. 在后台 `Token 管理` 页面导入 -### 第二步:导入到项目中 +支持: +- 粘贴 JSON 内容 +- 直接上传 `.json` 文件 +- 批量导入多个账号 -拿到导出的 JSON 文件后,按照以下流程导入服务: - -1. 访问并登录管理后台(默认 `http://127.0.0.1:6001/`) -2. 打开「Token 管理」页签 -3. 点击「导入 Cookie」按钮 -4. **方式 A:** 粘贴 JSON 文件内容到文本框;**方式 B:** 直接上传导出的 `.json` 文件 -5. 点击「确认导入」(服务会自动验证 Cookie 并执行一次刷新) -6. 导入成功后,Token 列表中会显示对应的 Token,且 `自动刷新` 状态为「是」 - -**批量导入:** 导入弹窗支持一次上传多个文件,或粘贴包含多个账户信息的 JSON 数组。 - -## 5)存储路径 +## 5. 存储路径 - 生成媒体文件:`data/generated/` - 请求日志:`data/request_logs.jsonl` - Token 池:`config/tokens.json` - 服务配置:`config/config.json` -- 刷新配置(本地私有):`config/refresh_profile.json` - -生成媒体保留策略: - -- `data/generated/` 下文件会保留,并通过 `/generated/*` 对外访问 -- 启用按容量阈值自动清理(最旧文件优先) - - `generated_max_size_mb`(默认 `1024`) - - `generated_prune_size_mb`(默认 `200`) -- 当总大小超过 `generated_max_size_mb` 时,服务会删除旧文件,直到至少回收 `generated_prune_size_mb`且总大小降回阈值以内 +- 刷新配置:`config/refresh_profile.json` ## Star History diff --git a/README_EN.md b/README_EN.md index 73896db..12d22c7 100644 --- a/README_EN.md +++ b/README_EN.md @@ -68,100 +68,101 @@ Admin UI and admin APIs require login session cookie via `/api/v1/auth/login`. Current supported model families are: -- `firefly-nano-banana-*` (image, maps to upstream `nano-banana-2`) -- `firefly-nano-banana2-*` (image, maps to upstream `nano-banana-3`) -- `firefly-nano-banana-pro-*` (image) -- `firefly-sora2-*` (video) -- `firefly-sora2-pro-*` (video) -- `firefly-veo31-*` (video) -- `firefly-veo31-ref-*` (video, reference-image mode) -- `firefly-veo31-fast-*` (video) +- `firefly-nano-banana` (image, maps to upstream `nano-banana-2`) +- `firefly-nano-banana2` (image, maps to upstream `nano-banana-3`) +- `firefly-nano-banana-pro` (image) +- `firefly-sora2` (video) +- `firefly-sora2-pro` (video) +- `firefly-veo31` (video) +- `firefly-veo31-ref` (video, reference-image mode) +- `firefly-veo31-fast` (video) Nano Banana image models (`nano-banana-2`): -- Pattern: `firefly-nano-banana-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana-2k-16x9` - - `firefly-nano-banana-4k-1x1` + - `model=firefly-nano-banana, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana, output_resolution=4K, aspect_ratio=1:1` Nano Banana 2 image models (`nano-banana-3`): -- Pattern: `firefly-nano-banana2-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana2` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana2-2k-16x9` - - `firefly-nano-banana2-4k-1x1` + - `model=firefly-nano-banana2, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana2, output_resolution=4K, aspect_ratio=1:1` Nano Banana Pro image models (legacy-compatible): -- Pattern: `firefly-nano-banana-pro-{resolution}-{ratio}` -- Resolution: `1k` / `2k` / `4k` -- Ratio suffix: `1x1` / `16x9` / `9x16` / `4x3` / `3x4` +- Pattern: `model=firefly-nano-banana-pro` with separate request fields +- Resolution: pass `output_resolution` as `1K` / `2K` / `4K` +- Ratio: pass `aspect_ratio` as `1:1` / `16:9` / `9:16` / `4:3` / `3:4` - Examples: - - `firefly-nano-banana-pro-2k-16x9` - - `firefly-nano-banana-pro-4k-1x1` + - `model=firefly-nano-banana-pro, output_resolution=2K, aspect_ratio=16:9` + - `model=firefly-nano-banana-pro, output_resolution=4K, aspect_ratio=1:1` Sora2 video models: -- Pattern: `firefly-sora2-{duration}-{ratio}` -- Duration: `4s` / `8s` / `12s` -- Ratio: `9x16` / `16x9` +- Pattern: `model=firefly-sora2` with separate request fields +- Duration: pass `duration` as `4` / `8` / `12` +- Ratio: pass `aspect_ratio` as `9:16` / `16:9` - Examples: - - `firefly-sora2-4s-16x9` - - `firefly-sora2-8s-9x16` + - `model=firefly-sora2, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2, duration=8, aspect_ratio=9:16` Sora2 Pro video models: -- Pattern: `firefly-sora2-pro-{duration}-{ratio}` -- Duration: `4s` / `8s` / `12s` -- Ratio: `9x16` / `16x9` +- Pattern: `model=firefly-sora2-pro` with separate request fields +- Duration: pass `duration` as `4` / `8` / `12` +- Ratio: pass `aspect_ratio` as `9:16` / `16:9` - Examples: - - `firefly-sora2-pro-4s-16x9` - - `firefly-sora2-pro-8s-9x16` + - `model=firefly-sora2-pro, duration=4, aspect_ratio=16:9` + - `model=firefly-sora2-pro, duration=8, aspect_ratio=9:16` Veo31 video models: -- Pattern: `firefly-veo31-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` +- Pattern: `model=firefly-veo31` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` +- Reference mode: pass `reference_mode` as `frame` or `image` - Supports up to 2 reference images: - 1 image: first-frame reference - 2 images: first-frame + last-frame reference +- In `reference_mode=image`, supports up to 3 reference images - Audio defaults to enabled - Examples: - - `firefly-veo31-4s-16x9-1080p` - - `firefly-veo31-6s-9x16-720p` + - `model=firefly-veo31, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31, duration=6, aspect_ratio=9:16, resolution=720p, reference_mode=image` -Veo31 Ref video models (reference-image mode): +Veo31 Ref video models: -- Pattern: `firefly-veo31-ref-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` -- Always uses reference image mode (not first/last frame mode) -- Supports up to 3 reference images (mapped to upstream `referenceBlobs[].usage="asset"`) +- Pattern: `model=firefly-veo31-ref` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` +- Always uses reference image mode +- Supports up to 3 reference images - Examples: - - `firefly-veo31-ref-4s-9x16-720p` - - `firefly-veo31-ref-6s-16x9-1080p` - - `firefly-veo31-ref-8s-9x16-1080p` + - `model=firefly-veo31-ref, duration=4, aspect_ratio=9:16, resolution=720p` + - `model=firefly-veo31-ref, duration=6, aspect_ratio=16:9, resolution=1080p` Veo31 Fast video models: -- Pattern: `firefly-veo31-fast-{duration}-{ratio}-{resolution}` -- Duration: `4s` / `6s` / `8s` -- Ratio: `16x9` / `9x16` -- Resolution: `1080p` / `720p` +- Pattern: `model=firefly-veo31-fast` with separate request fields +- Duration: pass `duration` as `4` / `6` / `8` +- Ratio: pass `aspect_ratio` as `16:9` / `9:16` +- Resolution: pass `resolution` as `1080p` / `720p` - Supports up to 2 reference images: - 1 image: first-frame reference - 2 images: first-frame + last-frame reference - Audio defaults to enabled - Examples: - - `firefly-veo31-fast-4s-16x9-1080p` - - `firefly-veo31-fast-6s-9x16-720p` + - `model=firefly-veo31-fast, duration=4, aspect_ratio=16:9, resolution=1080p` + - `model=firefly-veo31-fast, duration=6, aspect_ratio=9:16, resolution=720p` ### 3.1 List models @@ -179,7 +180,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{"role":"user","content":"a cinematic mountain sunrise"}] }' ``` @@ -191,7 +194,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-2k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "2K", + "aspect_ratio": "16:9", "messages": [{ "role":"user", "content":[ @@ -209,17 +214,48 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-4s-16x9", + "model": "firefly-sora2", + "duration": 4, + "aspect_ratio": "16:9", + "messages": [{"role":"user","content":"a drone shot over snowy forest"}] + }' +``` + +Optional Sora-only controls: + +- `locale`: overrides the default `en-US` +- `timeline_events`: adds structured timeline hints into the Sora prompt JSON +- `audio`: adds optional structured audio hints into the Sora prompt JSON + +Example: + +```bash +curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ + -H "Authorization: Bearer " \ + -H "Content-Type: application/json" \ + -d '{ + "model": "firefly-sora2", + "duration": 4, + "aspect_ratio": "16:9", + "locale": "ja-JP", + "audio": { + "sfx": "Wind howling softly", + "voice_timbre": "Natural, calm voice" + }, + "timeline_events": { + "0s-2s": "Camera holds on the snowy forest", + "2s-4s": "Drone glides forward slowly" + }, "messages": [{"role":"user","content":"a drone shot over snowy forest"}] }' ``` Veo31 single-image semantics: -- `firefly-veo31-*` / `firefly-veo31-fast-*`: frame mode +- `firefly-veo31` / `firefly-veo31-fast` with `reference_mode=frame`: frame mode - 1 image => first frame - 2 images => first frame + last frame -- `firefly-veo31-ref-*`: reference-image mode +- `firefly-veo31-ref` or `firefly-veo31` with `reference_mode=image`: reference-image mode - 1~3 images => reference images Image-to-video: @@ -229,7 +265,9 @@ curl -X POST "http://127.0.0.1:6001/v1/chat/completions" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-sora2-8s-9x16", + "model": "firefly-sora2", + "duration": 8, + "aspect_ratio": "9:16", "messages": [{ "role":"user", "content":[ @@ -247,7 +285,9 @@ curl -X POST "http://127.0.0.1:6001/v1/images/generations" \ -H "Authorization: Bearer " \ -H "Content-Type: application/json" \ -d '{ - "model": "firefly-nano-banana-pro-4k-16x9", + "model": "firefly-nano-banana-pro", + "output_resolution": "4K", + "aspect_ratio": "16:9", "prompt": "futuristic city skyline at dusk" }' ``` diff --git a/api/routes/admin.py b/api/routes/admin.py index 5a1a751..0222a60 100644 --- a/api/routes/admin.py +++ b/api/routes/admin.py @@ -12,6 +12,7 @@ AdminLoginRequest, ConfigUpdateRequest, ExportSelectionRequest, + ProxyTestRequest, RefreshCookieBatchImportRequest, RefreshCookieImportRequest, RefreshProfileEnabledRequest, @@ -19,6 +20,12 @@ TokenBatchAddRequest, TokenCreditsBatchRefreshRequest, ) +from core.proxy_utils import ( + resolve_basic_proxy, + resolve_resource_proxy, + test_authorized_endpoint, + test_proxy_endpoint, +) def build_admin_router( @@ -34,6 +41,7 @@ def build_admin_router( is_admin_authenticated: Callable[[Request], bool], apply_client_config: Callable[[], None], get_generated_storage_stats: Callable[[], dict[str, Any]], + get_redis_health: Callable[[], dict[str, Any]], ) -> APIRouter: router = APIRouter() @@ -59,9 +67,128 @@ def delete_token_and_linked_profile(token_id: str) -> bool: token_manager.remove(token_id) return True + def build_basic_business_proxy_result(proxy: str) -> dict[str, Any]: + result = { + "name": "basic_business", + "enabled": bool(proxy), + "ok": False, + "target_url": "https://firefly.adobe.io/v1/credits/balance", + "proxy": proxy, + "elapsed_ms": 0, + "status_code": None, + "message": "", + "token_id": "", + "token_source": "", + "token_preview": "", + "account_id": "", + } + if not proxy: + result["message"] = "basic proxy disabled" + return result + + active_ids = [] + try: + active_ids = token_manager.list_active_ids() + except Exception: + active_ids = [] + token_info = None + for token_id in active_ids: + token_info = token_manager.get_by_id(token_id) + if token_info and str(token_info.get("value") or "").strip(): + break + if not token_info: + result["message"] = "no active token available for business auth test" + return result + + token_value = str(token_info.get("value") or "").strip() + token_id = str(token_info.get("id") or "").strip() + token_source = str(token_info.get("source") or "manual").strip() + token_preview = ( + token_value[:10] + "..." + token_value[-6:] + if len(token_value) > 20 + else "***" + ) + account_id = "" + try: + account_id = str(refresh_manager._extract_account_id(token_value) or "").strip() + except Exception: + account_id = "" + + result.update( + { + "token_id": token_id, + "token_source": token_source, + "token_preview": token_preview, + "account_id": account_id, + } + ) + if not account_id: + result["message"] = "active token found, but account_id could not be extracted" + return result + + auth_result = test_authorized_endpoint( + check_name="basic_business", + proxy=proxy, + target_url="https://firefly.adobe.io/v1/credits/balance", + headers={ + "Authorization": f"Bearer {token_value}", + "x-api-key": "SunbreakWebUI1", + "x-account-id": account_id, + "Accept": "application/json", + "Content-Type": "application/json", + }, + ) + auth_result.update( + { + "token_id": token_id, + "token_source": token_source, + "token_preview": token_preview, + "account_id": account_id, + } + ) + return auth_result + @router.get("/api/v1/health") def health(): - return {"status": "ok", "pool_size": len(token_manager.list_all())} + return { + "status": "ok", + "pool_size": len(token_manager.list_all()), + "redis": get_redis_health(), + } + + @router.get("/api/v1/health/redis") + def health_redis(): + return get_redis_health() + + @router.post("/api/v1/proxy/test") + def test_proxy(req: ProxyTestRequest, request: Request): + require_admin_auth(request) + cfg = config_manager.get_all() + incoming = req.model_dump(exclude_unset=True) + cfg.update(incoming) + basic_proxy = resolve_basic_proxy(cfg) + resource_proxy = resolve_resource_proxy(cfg) + basic_result = test_proxy_endpoint( + proxy_label="basic", + proxy=basic_proxy, + target_url="https://firefly.adobe.io/v1/credits/balance", + ) + resource_result = test_proxy_endpoint( + proxy_label="resource", + proxy=resource_proxy, + target_url="https://firefly-3p.ff.adobe.io/v2/storage/image", + ) + basic_business_result = build_basic_business_proxy_result(basic_proxy) + return { + "status": "ok", + "connectivity": { + "basic": basic_result, + "resource": resource_result, + }, + "business": { + "basic": basic_business_result, + }, + } @router.get("/login", include_in_schema=False) def page_login(request: Request): @@ -110,9 +237,22 @@ def page_root(request: Request): return FileResponse(static_dir / "admin.html") @router.get("/api/v1/logs") - def list_logs(request: Request, limit: int = 20, page: int = 1): + def list_logs( + request: Request, + limit: int = 20, + page: int = 1, + failed_only: bool = False, + account: str = "", + media_kind: str = "", + ): require_admin_auth(request) - logs, total = log_store.list(limit=limit, page=page) + logs, total = log_store.list( + limit=limit, + page=page, + failed_only=bool(failed_only), + account=str(account or "").strip(), + media_kind=str(media_kind or "").strip().lower(), + ) safe_limit = min(max(int(limit or 20), 1), 100) safe_page = max(int(page or 1), 1) total_pages = (total + safe_limit - 1) // safe_limit if total > 0 else 1 @@ -124,8 +264,19 @@ def list_logs(request: Request, limit: int = 20, page: int = 1): "limit": safe_limit, "total": total, "total_pages": total_pages, + "filters": { + "failed_only": bool(failed_only), + "account": str(account or "").strip(), + "media_kind": str(media_kind or "").strip().lower(), + }, } + @router.get("/api/v1/logs/failed-accounts") + def list_failed_accounts(request: Request, limit: int = 200): + require_admin_auth(request) + items = log_store.list_failed_accounts(limit=limit) + return {"items": items, "total": len(items)} + @router.get("/api/v1/logs/errors/{code}") def get_error_detail(code: str, request: Request): require_admin_auth(request) @@ -154,15 +305,26 @@ def _resolve_logs_stats_range(range_key: str) -> tuple[str, float, float]: key = str(range_key or "today").strip().lower() if key == "today": start_dt = datetime(now_dt.year, now_dt.month, now_dt.day) + end_ts = now_ts + elif key == "yesterday": + today_start = datetime(now_dt.year, now_dt.month, now_dt.day) + start_dt = today_start - timedelta(days=1) + end_ts = today_start.timestamp() + elif key == "3d": + start_dt = now_dt - timedelta(days=3) + end_ts = now_ts elif key == "7d": start_dt = now_dt - timedelta(days=7) + end_ts = now_ts elif key == "30d": start_dt = now_dt - timedelta(days=30) + end_ts = now_ts else: raise HTTPException( - status_code=400, detail="range must be one of: today, 7d, 30d" + status_code=400, + detail="range must be one of: today, yesterday, 3d, 7d, 30d", ) - return key, start_dt.timestamp(), now_ts + return key, start_dt.timestamp(), end_ts @router.get("/api/v1/logs/stats") def logs_stats(request: Request, range: str = "today"): @@ -443,6 +605,21 @@ def update_config(req: ConfigUpdateRequest, request: Request): update_data["proxy"] = str(incoming["proxy"] or "").strip() if "use_proxy" in incoming: update_data["use_proxy"] = bool(incoming["use_proxy"]) + if "resource_proxy" in incoming: + update_data["resource_proxy"] = str(incoming["resource_proxy"] or "").strip() + if "resource_use_proxy" in incoming: + update_data["resource_use_proxy"] = bool(incoming["resource_use_proxy"]) + effective_basic_use_proxy = bool( + update_data.get("use_proxy", config_manager.get("use_proxy", False)) + ) + effective_basic_proxy = str( + update_data.get("proxy", config_manager.get("proxy", "")) or "" + ).strip() + if effective_basic_use_proxy and not effective_basic_proxy.startswith(("http://", "https://")): + raise HTTPException( + status_code=400, + detail="proxy must start with http:// or https:// when basic proxy is enabled", + ) if "generate_timeout" in incoming: try: timeout_val = int(incoming["generate_timeout"]) @@ -576,6 +753,52 @@ def update_config(req: ConfigUpdateRequest, request: Request): detail="generated_prune_size_mb must be between 10 and 10240", ) update_data["generated_prune_size_mb"] = generated_prune_size_mb + if "use_upstream_result_url" in incoming: + update_data["use_upstream_result_url"] = bool( + incoming["use_upstream_result_url"] + ) + if "imgbed_enabled" in incoming: + update_data["imgbed_enabled"] = bool(incoming["imgbed_enabled"]) + if "imgbed_api_url" in incoming: + update_data["imgbed_api_url"] = str(incoming["imgbed_api_url"] or "").strip() + if "imgbed_api_key" in incoming: + update_data["imgbed_api_key"] = str(incoming["imgbed_api_key"] or "").strip() + effective_resource_use_proxy = bool( + update_data.get( + "resource_use_proxy", config_manager.get("resource_use_proxy", False) + ) + ) + effective_resource_proxy = str( + update_data.get("resource_proxy", config_manager.get("resource_proxy", "")) + or "" + ).strip() + if effective_resource_use_proxy and not effective_resource_proxy.startswith(("http://", "https://")): + raise HTTPException( + status_code=400, + detail="resource_proxy must start with http:// or https:// when resource proxy is enabled", + ) + effective_imgbed_enabled = bool( + update_data.get("imgbed_enabled", config_manager.get("imgbed_enabled", False)) + ) + effective_imgbed_api_url = str( + update_data.get("imgbed_api_url", config_manager.get("imgbed_api_url", "")) + or "" + ).strip() + effective_imgbed_api_key = str( + update_data.get("imgbed_api_key", config_manager.get("imgbed_api_key", "")) + or "" + ).strip() + if effective_imgbed_enabled: + if not effective_imgbed_api_url.startswith(("http://", "https://")): + raise HTTPException( + status_code=400, + detail="imgbed_api_url must start with http:// or https:// when imgbed is enabled", + ) + if not effective_imgbed_api_key: + raise HTTPException( + status_code=400, + detail="imgbed_api_key cannot be empty when imgbed is enabled", + ) effective_max = int( update_data.get( "generated_max_size_mb", diff --git a/api/routes/generation.py b/api/routes/generation.py index 921df09..a51f62e 100644 --- a/api/routes/generation.py +++ b/api/routes/generation.py @@ -11,9 +11,204 @@ from api.schemas import GenerateRequest +def _validate_prompt_length(prompt: str) -> None: + if len(str(prompt or "").strip()) < 3: + raise HTTPException( + status_code=400, + detail="prompt must contain at least 3 characters", + ) + + +def _normalize_upstream_request_error(exc: Exception) -> tuple[int, str, str] | None: + message = str(exc or "").strip() + lowered = message.lower() + if ("poll failed: 400" in lowered or "submit failed: 400" in lowered) and ( + "validation error" in lowered + or "字符串应至少包含 3 个字符" in message + or "string should have at least 3 characters" in lowered + ): + return ( + 400, + "invalid_request_error", + "prompt must contain at least 3 characters", + ) + return None + + +def _extract_upstream_asset_url(meta: dict, asset_kind: str) -> str: + outputs = meta.get("outputs") or [] + if not outputs: + return "" + asset = (outputs[0] or {}).get(asset_kind) or {} + return str(asset.get("presignedUrl") or "").strip() + + +def _video_mime_type(video_ext: str) -> str: + normalized = str(video_ext or "").strip().lower() + if normalized == "mov": + return "video/quicktime" + if normalized == "webm": + return "video/webm" + return "video/mp4" + + +def _looks_like_video_model_id(model_id: str) -> bool: + normalized = str(model_id or "").strip().lower() + return normalized.startswith( + ( + "sora2", + "veo31-fast", + "veo31-", + "firefly-sora2", + "firefly-veo31-fast", + "firefly-veo31-", + ) + ) + + +def _resolve_sora_video_extras(data: dict) -> tuple[str, dict | None, dict | None]: + locale = str( + data.get("locale") + or data.get("video_locale") + or data.get("videoLocale") + or "en-US" + ).strip() or "en-US" + if len(locale) > 32: + locale = locale[:32] + + timeline_events = ( + data.get("timeline_events") + or data.get("timelineEvents") + or data.get("video_timeline_events") + or data.get("videoTimelineEvents") + ) + if not isinstance(timeline_events, dict): + timeline_events = None + elif not timeline_events: + timeline_events = None + + audio = data.get("audio") or data.get("video_audio") or data.get("videoAudio") + if not isinstance(audio, dict): + audio = None + elif not audio: + audio = None + + return locale, timeline_events, audio + + +def _coerce_video_duration(value: Any, allowed: list[int], default: int) -> int: + if value is None or str(value).strip() == "": + return default + try: + parsed = int(str(value).strip().rstrip("sS")) + except Exception: + raise HTTPException(status_code=400, detail="unsupported duration") + if parsed not in allowed: + raise HTTPException(status_code=400, detail="unsupported duration") + return parsed + + +def _coerce_video_resolution( + value: Any, allowed: list[str], default: str | None +) -> str | None: + if not allowed: + return default + if value is None or str(value).strip() == "": + return default + normalized = str(value).strip().lower() + resolution_aliases = { + "720": "720p", + "720p": "720p", + "1080": "1080p", + "1080p": "1080p", + "fhd": "1080p", + "fullhd": "1080p", + } + resolved = resolution_aliases.get(normalized, normalized) + if resolved not in allowed: + raise HTTPException(status_code=400, detail="unsupported resolution") + return resolved + + +def _resolve_video_request_config(model_id: str, data: dict, video_conf: dict) -> dict: + resolved = dict(video_conf or {}) + allow_request_overrides = bool(resolved.get("allow_request_overrides")) + + if not allow_request_overrides: + resolved["resolved_model_id"] = str(resolved.get("canonical_model") or model_id) + return resolved + + duration_options = [ + int(item) + for item in (resolved.get("duration_options") or []) + if str(item).strip() + ] + aspect_ratio_options = [ + str(item).strip() + for item in (resolved.get("aspect_ratio_options") or []) + if str(item).strip() + ] + resolution_options = [ + str(item).strip().lower() + for item in (resolved.get("resolution_options") or []) + if str(item).strip() + ] + reference_mode_options = [ + str(item).strip().lower() + for item in (resolved.get("reference_mode_options") or []) + if str(item).strip() + ] + + default_duration = int(resolved.get("duration") or (duration_options[0] if duration_options else 8)) + default_ratio = str( + resolved.get("aspect_ratio") or (aspect_ratio_options[0] if aspect_ratio_options else "16:9") + ).strip() + default_resolution = ( + str(resolved.get("resolution") or (resolution_options[0] if resolution_options else "")).strip().lower() + or None + ) + default_reference_mode = str( + resolved.get("reference_mode") or (reference_mode_options[0] if reference_mode_options else "frame") + ).strip().lower() + + requested_ratio = str(data.get("aspect_ratio") or "").strip() + if not requested_ratio and aspect_ratio_options: + requested_ratio = default_ratio + if requested_ratio and aspect_ratio_options and requested_ratio not in aspect_ratio_options: + raise HTTPException(status_code=400, detail="unsupported aspect_ratio") + + requested_resolution = ( + data.get("resolution") + or data.get("video_resolution") + or data.get("output_resolution") + ) + requested_reference_mode = str( + data.get("reference_mode") or data.get("video_reference_mode") or default_reference_mode + ).strip().lower() or default_reference_mode + if reference_mode_options and requested_reference_mode not in reference_mode_options: + raise HTTPException(status_code=400, detail="unsupported reference_mode") + + resolved["duration"] = _coerce_video_duration( + data.get("duration") or data.get("video_duration"), + duration_options, + default_duration, + ) + resolved["aspect_ratio"] = requested_ratio or default_ratio + resolved["resolution"] = _coerce_video_resolution( + requested_resolution, + resolution_options, + default_resolution, + ) + resolved["reference_mode"] = requested_reference_mode + resolved["resolved_model_id"] = str(resolved.get("canonical_model") or model_id) + return resolved + + def build_generation_router( *, store, + request_log_store, + live_request_store, token_manager, client, generated_dir: Path, @@ -29,6 +224,9 @@ def build_generation_router( set_request_preview: Callable[[Request, str, str], None], public_image_url: Callable[[Request, str], str], public_generated_url: Callable[[Request, str], str], + use_upstream_result_url: Callable[[], bool], + use_imgbed_upload: Callable[[], bool], + upload_generated_asset_to_imgbed: Callable[[str, str, str | None], str], resolve_video_options: Callable[[dict], tuple[bool, str, str]], load_input_images: Callable[[Any], list[tuple[bytes, str]]], prepare_video_source_image: Callable[[bytes, str, str], tuple[bytes, str]], @@ -43,27 +241,103 @@ def build_generation_router( ) -> APIRouter: router = APIRouter() + def _json_response(status_code: int, content: dict, request: Request) -> JSONResponse: + return JSONResponse(status_code=status_code, content=content) + + def _normalize_image_request_data(data: dict, prompt: str) -> dict: + normalized = dict(data or {}) + messages = normalized.get("messages") + if isinstance(messages, list) and messages: + return normalized + + image_urls: list[str] = [] + seen_urls: set[str] = set() + + def _append_image_url(value: Any) -> None: + raw_value = value + if isinstance(raw_value, dict): + raw_value = ( + raw_value.get("url") + or raw_value.get("image_url") + or raw_value.get("src") + ) + text = str(raw_value or "").strip() + if not text or text in seen_urls: + return + seen_urls.add(text) + image_urls.append(text) + + for key in ( + "image_url", + "image_urls", + "input_image", + "input_images", + "reference_image", + "reference_images", + ): + value = normalized.get(key) + if isinstance(value, list): + for item in value: + _append_image_url(item) + else: + _append_image_url(value) + + if not image_urls: + return normalized + + content: list[dict[str, Any]] = [] + if prompt: + content.append({"type": "text", "text": prompt}) + for image_url in image_urls[:6]: + content.append({"type": "image_url", "image_url": {"url": image_url}}) + normalized["messages"] = [{"role": "user", "content": content}] + return normalized + @router.get("/v1/models") def list_models(request: Request): require_service_api_key(request) data = [] for model_id, conf in model_catalog.items(): + if conf.get("hidden"): + continue + item = { + "id": model_id, + "object": "model", + "owned_by": "adobe2api", + "description": conf["description"], + } + parameters = {} + if conf.get("output_resolution_options"): + parameters["output_resolution"] = conf["output_resolution_options"] + if conf.get("aspect_ratio_options"): + parameters["aspect_ratio"] = conf["aspect_ratio_options"] + if parameters: + item["parameters"] = parameters data.append( - { - "id": model_id, - "object": "model", - "owned_by": "adobe2api", - "description": conf["description"], - } + item ) for model_id, conf in video_model_catalog.items(): + if conf.get("hidden"): + continue + item = { + "id": model_id, + "object": "model", + "owned_by": "adobe2api", + "description": conf["description"], + } + parameters = {} + if conf.get("duration_options"): + parameters["duration"] = conf["duration_options"] + if conf.get("aspect_ratio_options"): + parameters["aspect_ratio"] = conf["aspect_ratio_options"] + if conf.get("resolution_options"): + parameters["resolution"] = conf["resolution_options"] + if conf.get("reference_mode_options"): + parameters["reference_mode"] = conf["reference_mode_options"] + if parameters: + item["parameters"] = parameters data.append( - { - "id": model_id, - "object": "model", - "owned_by": "adobe2api", - "description": conf["description"], - } + item ) return {"object": "list", "data": data} @@ -71,9 +345,12 @@ def list_models(request: Request): def openai_generate(data: dict, request: Request): require_service_api_key(request) - prompt = data.get("prompt", "").strip() + prompt = str(data.get("prompt") or "").strip() + normalized_data = _normalize_image_request_data(data, prompt) + if not prompt: + prompt = extract_prompt_from_messages(normalized_data.get("messages") or []) if not prompt: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { @@ -81,30 +358,42 @@ def openai_generate(data: dict, request: Request): "type": "invalid_request_error", } }, + request=request, ) + _validate_prompt_length(prompt) - model_id = data.get("model") + model_id = normalized_data.get("model") if str(model_id or "").strip() in video_model_catalog: - return JSONResponse( + return _json_response( status_code=400, content={ "error": { - "message": "Use /v1/chat/completions for video generation", + "message": "Use /v1/video/generations or /v1/chat/completions for video generation", "type": "invalid_request_error", } }, + request=request, ) ratio, output_resolution, resolved_model_id = resolve_ratio_and_resolution( - data, model_id + normalized_data, model_id ) model_conf = resolve_model(resolved_model_id) try: + input_images = load_input_images(normalized_data.get("messages") or []) set_request_task_progress( request, task_status="IN_PROGRESS", task_progress=0.0 ) def _run_once(token: str): + source_image_ids: list[str] = [] + for image_bytes, image_mime in input_images: + source_image_ids.append( + client.upload_image( + token, image_bytes, image_mime or "image/jpeg" + ) + ) + def _image_progress_cb(update: dict): set_request_task_progress( request, @@ -115,16 +404,19 @@ def _image_progress_cb(update: dict): error=update.get("error"), ) + imgbed_upload_enabled = bool(use_imgbed_upload()) + direct_result_url = bool(use_upstream_result_url()) or imgbed_upload_enabled job_id = uuid.uuid4().hex out_path = generated_dir / f"{job_id}.png" old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 - image_bytes, _meta = client.generate( + image_bytes, meta = client.generate( token=token, prompt=prompt, aspect_ratio=ratio, @@ -135,15 +427,37 @@ def _image_progress_cb(update: dict): upstream_model_version=str( model_conf.get("upstream_model_version") or "nano-banana-2" ), + source_image_ids=source_image_ids, timeout=client.generate_timeout, - out_path=out_path, + out_path=None if direct_result_url else out_path, progress_cb=_image_progress_cb, + return_upstream_url=direct_result_url, ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_image_url(request, job_id) + upstream_image_url = _extract_upstream_asset_url(meta, "image") + if imgbed_upload_enabled: + if not upstream_image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + image_url = upload_generated_asset_to_imgbed( + upstream_image_url, + filename=f"{job_id}.png", + mime_type="image/png", + ) + elif direct_result_url: + image_url = upstream_image_url + if not image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) set_request_preview(request, image_url, kind="image") return { "created": int(time.time()), @@ -173,7 +487,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token quota exhausted", ) - return JSONResponse( + return _json_response( status_code=429, content={ "error": { @@ -182,6 +496,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except auth_error_cls: error_code = str( @@ -199,7 +514,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token invalid or expired", ) - return JSONResponse( + return _json_response( status_code=401, content={ "error": { @@ -208,6 +523,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except upstream_temp_error_cls as exc: error_code = str( @@ -222,7 +538,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=503, content={ "error": { @@ -231,6 +547,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except HTTPException as exc: err_type = ( @@ -248,7 +565,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc.detail) ) - return JSONResponse( + return _json_response( status_code=exc.status_code, content={ "error": { @@ -257,8 +574,33 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except Exception as exc: + normalized = _normalize_upstream_request_error(exc) + if normalized is not None: + status_code, err_type, message = normalized + error_code = set_request_error_detail( + request, + error=message, + status_code=status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=message + ) + return _json_response( + status_code=status_code, + content={ + "error": { + "message": message, + "type": err_type, + "code": error_code, + } + }, + request=request, + ) error_code = set_request_error_detail( request, error=exc, @@ -274,7 +616,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=500, content={ "error": { @@ -283,6 +625,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) @router.post("/api/v1/generate") @@ -292,6 +635,7 @@ def create_job(data: GenerateRequest, request: Request): prompt = data.prompt.strip() if not prompt: raise HTTPException(status_code=400, detail="prompt cannot be empty") + _validate_prompt_length(prompt) ratio = data.aspect_ratio.strip() or "16:9" if ratio not in supported_ratios: @@ -321,13 +665,16 @@ def runner(job_id: str): break try: + imgbed_upload_enabled = bool(use_imgbed_upload()) + direct_result_url = bool(use_upstream_result_url()) or imgbed_upload_enabled out_path = generated_dir / f"{job_id}.png" old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 image_bytes, meta = client.generate( token=token, @@ -340,14 +687,31 @@ def runner(job_id: str): upstream_model_version=str( model_conf.get("upstream_model_version") or "nano-banana-2" ), - out_path=out_path, + out_path=None if direct_result_url else out_path, + return_upstream_url=direct_result_url, ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) + upstream_image_url = _extract_upstream_asset_url(meta, "image") + if imgbed_upload_enabled: + if not upstream_image_url: + raise RuntimeError("upstream result url missing") + image_url = upload_generated_asset_to_imgbed( + upstream_image_url, + filename=f"{job_id}.png", + mime_type="image/png", + ) + elif direct_result_url: + image_url = upstream_image_url + if not image_url: + raise RuntimeError("upstream result url missing") + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = ( + int(out_path.stat().st_size) if out_path.exists() else 0 + ) + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) progress = float(meta.get("progress") or 100.0) - image_url = public_image_url(request, job_id) store.update( job_id, status="succeeded", @@ -395,103 +759,204 @@ def get_job(task_id: str, request: Request): raise HTTPException(status_code=404, detail="task not found") return asdict(job) - @router.post("/v1/chat/completions") - def chat_completions(data: dict, request: Request): - require_service_api_key(request) + def _normalize_video_request_data(data: dict, prompt: str) -> dict: + normalized = dict(data or {}) + messages = normalized.get("messages") + if isinstance(messages, list) and messages: + return normalized - prompt = extract_prompt_from_messages(data.get("messages") or []) - if not prompt: - prompt = str(data.get("prompt") or "").strip() - if not prompt: - return JSONResponse( + image_urls: list[str] = [] + seen_urls: set[str] = set() + + def _append_image_url(value: Any) -> None: + raw_value = value + if isinstance(raw_value, dict): + raw_value = ( + raw_value.get("url") + or raw_value.get("image_url") + or raw_value.get("src") + ) + text = str(raw_value or "").strip() + if not text or text in seen_urls: + return + seen_urls.add(text) + image_urls.append(text) + + for key in ( + "image_url", + "image_urls", + "input_image", + "input_images", + "reference_image", + "reference_images", + ): + value = normalized.get(key) + if isinstance(value, list): + for item in value: + _append_image_url(item) + else: + _append_image_url(value) + + if not image_urls: + return normalized + + content: list[dict[str, Any]] = [] + if prompt: + content.append({"type": "text", "text": prompt}) + for image_url in image_urls[:6]: + content.append( + {"type": "image_url", "image_url": {"url": image_url}} + ) + normalized["messages"] = [{"role": "user", "content": content}] + return normalized + + def _wants_async_video_generation(data: dict) -> bool: + for key in ("async", "async_mode", "background"): + value = (data or {}).get(key) + if isinstance(value, bool): + if value: + return True + continue + if isinstance(value, (int, float)): + if value != 0: + return True + continue + if isinstance(value, str): + if value.strip().lower() in {"1", "true", "yes", "y", "on"}: + return True + return False + + def _format_video_generation_job(job) -> dict: + status = str(getattr(job, "status", "") or "queued").strip().lower() + public_status = "completed" if status == "succeeded" else status + video_url = str(getattr(job, "image_url", "") or "").strip() + payload = { + "id": f"vidgen-{str(job.id)[:24]}", + "object": "video.generation", + "created": int(float(getattr(job, "created_at", 0) or time.time())), + "model": getattr(job, "model", None), + "status": public_status, + "task_id": job.id, + "progress": float(getattr(job, "progress", 0.0) or 0.0), + } + if video_url: + payload["url"] = video_url + payload["video_url"] = video_url + payload["data"] = [{"url": video_url}] + error = str(getattr(job, "error", "") or "").strip() + if error: + payload["error"] = error + return payload + + def _create_async_video_generation(data: dict, request: Request, prompt: str): + normalized_data = _normalize_video_request_data(data, prompt) + model_id = str(normalized_data.get("model") or "").strip() + if not model_id: + return _json_response( status_code=400, content={ "error": { - "message": "messages or prompt is required", + "message": "model is required", "type": "invalid_request_error", } }, + request=request, ) - - model_id = str(data.get("model") or "").strip() - if ( - model_id.startswith("firefly-sora2") - or model_id.startswith("firefly-veo31-fast") - or model_id.startswith("firefly-veo31-") - ) and model_id not in video_model_catalog: - return JSONResponse( + if _looks_like_video_model_id(model_id) and model_id not in video_model_catalog: + return _json_response( status_code=400, content={ "error": { - "message": "Invalid video model. Use /v1/models to get supported firefly-sora2-*, firefly-veo31-* or firefly-veo31-fast-* models", + "message": "Invalid video model. Use /v1/models to get supported sora2, sora2-pro, veo31, veo31-ref or veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", "type": "invalid_request_error", } }, + request=request, ) video_conf = video_model_catalog.get(model_id) - is_video_model = video_conf is not None - resolved_model_id = model_id if is_video_model else None - ratio = "9:16" - output_resolution = "2K" - duration = int(video_conf["duration"]) if video_conf else 12 - video_resolution = ( - str(video_conf.get("resolution") or "720p") if video_conf else "720p" + if video_conf is None: + return _json_response( + status_code=400, + content={ + "error": { + "message": "Only video models are supported on /v1/video/generations", + "type": "invalid_request_error", + } + }, + request=request, + ) + + resolved_video_conf = _resolve_video_request_config( + model_id, normalized_data, video_conf or {} + ) + resolved_model_id = str( + resolved_video_conf.get("resolved_model_id") or model_id ) - if video_conf: - ratio = str(video_conf.get("aspect_ratio") or ratio) - video_engine = str(video_conf.get("engine") or "sora2") if video_conf else "" + ratio = str(resolved_video_conf.get("aspect_ratio") or "9:16") + duration = int(resolved_video_conf["duration"]) + video_resolution = str(resolved_video_conf.get("resolution") or "720p") + video_engine = str(resolved_video_conf.get("engine") or "sora2") generate_audio = True negative_prompt = "" - video_reference_mode = ( - str(video_conf.get("reference_mode") or "frame") if video_conf else "frame" + video_reference_mode = str( + resolved_video_conf.get("reference_mode") or "frame" ) - if is_video_model: - resolved_video_options = resolve_video_options(data) - if ( - isinstance(resolved_video_options, tuple) - and len(resolved_video_options) == 3 - ): - generate_audio, negative_prompt, requested_reference_mode = ( - resolved_video_options - ) - if "reference_mode" not in (video_conf or {}): - video_reference_mode = requested_reference_mode - else: - generate_audio, negative_prompt = resolved_video_options - else: - ratio, output_resolution, resolved_model_id = resolve_ratio_and_resolution( - data, model_id or None + resolved_video_options = resolve_video_options(normalized_data) + if ( + isinstance(resolved_video_options, tuple) + and len(resolved_video_options) == 3 + ): + generate_audio, negative_prompt, requested_reference_mode = ( + resolved_video_options ) - image_model_conf = ( - resolve_model(resolved_model_id) if not is_video_model else {} + if "reference_mode" not in (video_conf or {}): + video_reference_mode = requested_reference_mode + else: + generate_audio, negative_prompt = resolved_video_options + video_locale, timeline_events, video_audio = _resolve_sora_video_extras( + normalized_data ) - try: - input_images = load_input_images(data.get("messages") or []) - set_request_task_progress( - request, task_status="IN_PROGRESS", task_progress=0.0 - ) + job = store.create( + prompt=prompt, + aspect_ratio=ratio, + model=resolved_model_id, + kind="video", + ) - def _run_once(token: str): - source_image_ids: list[str] = [] - image_url = "" - response_content = "" - - if is_video_model: - if ( - video_engine == "veo31-standard" - and video_reference_mode == "image" - ): - max_video_inputs = 3 - else: - max_video_inputs = ( - 2 if video_engine in {"veo31-fast", "veo31-standard"} else 1 - ) - if len(input_images) > max_video_inputs: - raise HTTPException( - status_code=400, - detail=f"video model supports at most {max_video_inputs} input image(s)", - ) + def runner(job_id: str) -> None: + store.update(job_id, status="running", progress=1.0) + max_attempts = client.retry_max_attempts if client.retry_enabled else 1 + max_attempts = max(1, int(max_attempts)) + last_error = "No active tokens available in the pool" + try: + input_images = load_input_images(normalized_data.get("messages") or []) + if ( + video_engine == "veo31-standard" + and video_reference_mode == "image" + ): + max_video_inputs = 3 + else: + max_video_inputs = ( + 2 if video_engine in {"veo31-fast", "veo31-standard"} else 1 + ) + if len(input_images) > max_video_inputs: + raise RuntimeError( + f"video model supports at most {max_video_inputs} input image(s)" + ) + except Exception as exc: + store.update(job_id, status="failed", progress=0.0, error=str(exc)) + return + + for attempt in range(1, max_attempts + 1): + token = token_manager.get_available( + strategy=client.token_rotation_strategy + ) + if not token: + break + + try: + source_image_ids: list[str] = [] for image_bytes, _image_mime in input_images[:max_video_inputs]: prepared_bytes, prepared_mime = prepare_video_source_image( image_bytes, @@ -503,27 +968,35 @@ def _run_once(token: str): ) def _video_progress_cb(update: dict): - set_request_task_progress( - request, - task_status=str(update.get("task_status") or "IN_PROGRESS"), - task_progress=update.get("task_progress"), - upstream_job_id=update.get("upstream_job_id"), - retry_after=update.get("retry_after"), - error=update.get("error"), - ) + progress = update.get("task_progress") + try: + progress_value = float(progress) + except Exception: + progress_value = None + patch = {"status": "running"} + if progress_value is not None: + patch["progress"] = max(1.0, min(progress_value, 99.0)) + error_text = str(update.get("error") or "").strip() + if error_text: + patch["error"] = error_text + store.update(job_id, **patch) - job_id = uuid.uuid4().hex + imgbed_upload_enabled = bool(use_imgbed_upload()) + direct_result_url = ( + bool(use_upstream_result_url()) or imgbed_upload_enabled + ) tmp_path = generated_dir / f"{job_id}.video.tmp" old_size = 0 - try: - if tmp_path.exists(): - old_size = int(tmp_path.stat().st_size) - except Exception: - old_size = 0 + if not direct_result_url: + try: + if tmp_path.exists(): + old_size = int(tmp_path.stat().st_size) + except Exception: + old_size = 0 video_bytes, video_meta = client.generate_video( token=token, - video_conf=video_conf or {}, + video_conf=resolved_video_conf or {}, prompt=prompt, aspect_ratio=ratio, duration=duration, @@ -531,105 +1004,690 @@ def _video_progress_cb(update: dict): timeout=max(int(client.generate_timeout), 600), negative_prompt=negative_prompt, generate_audio=generate_audio, + locale=video_locale, + timeline_events=timeline_events, + audio=video_audio, reference_mode=video_reference_mode, - out_path=tmp_path, + out_path=None if direct_result_url else tmp_path, progress_cb=_video_progress_cb, + return_upstream_url=direct_result_url, ) - video_ext = video_ext_from_meta(video_meta) - filename = f"{job_id}.{video_ext}" - out_path = generated_dir / filename - if video_bytes is not None: - out_path.write_bytes(video_bytes) - elif tmp_path.exists(): - tmp_path.replace(out_path) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_generated_url(request, filename) - set_request_preview(request, image_url, kind="video") - response_content = ( - f"```html\n\n```" + upstream_video_url = _extract_upstream_asset_url( + video_meta, "video" ) - else: - for image_bytes, image_mime in input_images: - source_image_ids.append( - client.upload_image( - token, image_bytes, image_mime or "image/jpeg" - ) + video_ext = video_ext_from_meta(video_meta) + if imgbed_upload_enabled: + if not upstream_video_url: + raise RuntimeError("upstream result url missing") + video_url = upload_generated_asset_to_imgbed( + upstream_video_url, + filename=f"{job_id}.{video_ext}", + mime_type=_video_mime_type(video_ext), ) + elif direct_result_url: + video_url = upstream_video_url + if not video_url: + raise RuntimeError("upstream result url missing") + else: + filename = f"{job_id}.{video_ext}" + out_path = generated_dir / filename + if video_bytes is not None: + out_path.write_bytes(video_bytes) + elif tmp_path.exists(): + tmp_path.replace(out_path) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + video_url = public_generated_url(request, filename) - def _image_progress_cb(update: dict): - set_request_task_progress( - request, - task_status=str(update.get("task_status") or "IN_PROGRESS"), - task_progress=update.get("task_progress"), - upstream_job_id=update.get("upstream_job_id"), - retry_after=update.get("retry_after"), - error=update.get("error"), - ) + store.update( + job_id, + status="succeeded", + progress=100.0, + image_url=video_url, + error=None, + ) + return + except quota_error_cls: + token_manager.report_exhausted(token) + last_error = "Token quota exhausted." + retryable = attempt < max_attempts + except auth_error_cls: + token_manager.report_invalid(token) + last_error = "Token invalid or expired." + retryable = attempt < max_attempts + except upstream_temp_error_cls as exc: + last_error = str(exc) + retryable = ( + attempt < max_attempts + and client.should_retry_temporary_error(exc) + ) + except Exception as exc: + store.update(job_id, status="failed", error=str(exc)) + return - job_id = uuid.uuid4().hex - out_path = generated_dir / f"{job_id}.png" - old_size = 0 - try: - if out_path.exists(): - old_size = int(out_path.stat().st_size) - except Exception: - old_size = 0 + if retryable: + delay = client._retry_delay_for_attempt(attempt) + if delay > 0: + time.sleep(delay) + continue + break - image_bytes, _meta = client.generate( - token=token, - prompt=prompt, - aspect_ratio=ratio, - output_resolution=output_resolution, - upstream_model_id=str( - image_model_conf.get("upstream_model_id") or "gemini-flash" - ), - upstream_model_version=str( - image_model_conf.get("upstream_model_version") - or "nano-banana-2" - ), - source_image_ids=source_image_ids, - timeout=client.generate_timeout, - out_path=out_path, - progress_cb=_image_progress_cb, - ) - if image_bytes is not None: - out_path.write_bytes(image_bytes) - new_size = int(out_path.stat().st_size) if out_path.exists() else 0 - on_generated_file_written(out_path, old_size, new_size) - image_url = public_image_url(request, job_id) - set_request_preview(request, image_url, kind="image") - response_content = f"![Generated Image]({image_url})" + store.update(job_id, status="failed", error=last_error) - response_payload = { - "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", - "object": "chat.completion", - "created": int(time.time()), - "model": resolved_model_id, - "choices": [ - { - "index": 0, - "message": { - "role": "assistant", - "content": response_content, - }, - "finish_reason": "stop", - } - ], - "usage": { - "prompt_tokens": 0, - "completion_tokens": 0, - "total_tokens": 0, - }, - } - if bool(data.get("stream", False)): - return StreamingResponse( - sse_chat_stream(response_payload), - media_type="text/event-stream", - ) - return response_payload + threading.Thread(target=runner, args=(job.id,), daemon=True).start() + return _json_response( + status_code=202, + content=_format_video_generation_job(job), + request=request, + ) - return run_with_token_retries( + def _handle_video_generation_request( + data: dict, + request: Request, + *, + prompt: str, + response_mode: str, + ): + route_path = ( + "/v1/video/generations" + if response_mode == "video" + else "/v1/chat/completions" + ) + operation_name = ( + "video.generations" if response_mode == "video" else "chat.completions" + ) + normalized_data = _normalize_video_request_data(data, prompt) + model_id = str(normalized_data.get("model") or "").strip() + if not model_id: + return _json_response( + status_code=400, + content={ + "error": { + "message": "model is required", + "type": "invalid_request_error", + } + }, + request=request, + ) + if _looks_like_video_model_id(model_id) and model_id not in video_model_catalog: + return _json_response( + status_code=400, + content={ + "error": { + "message": "Invalid video model. Use /v1/models to get supported sora2, sora2-pro, veo31, veo31-ref or veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", + "type": "invalid_request_error", + } + }, + request=request, + ) + video_conf = video_model_catalog.get(model_id) + if video_conf is None: + return _json_response( + status_code=400, + content={ + "error": { + "message": f"Only video models are supported on {route_path}", + "type": "invalid_request_error", + } + }, + request=request, + ) + if response_mode == "video" and bool(normalized_data.get("stream", False)): + return _json_response( + status_code=400, + content={ + "error": { + "message": "stream is not supported on /v1/video/generations", + "type": "invalid_request_error", + } + }, + request=request, + ) + + resolved_video_conf = _resolve_video_request_config( + model_id, normalized_data, video_conf or {} + ) + resolved_model_id = str( + resolved_video_conf.get("resolved_model_id") or model_id + ) + ratio = str(resolved_video_conf.get("aspect_ratio") or "9:16") + duration = int(resolved_video_conf["duration"]) + video_resolution = str(resolved_video_conf.get("resolution") or "720p") + video_engine = str(resolved_video_conf.get("engine") or "sora2") + generate_audio = True + negative_prompt = "" + video_locale = "en-US" + timeline_events = None + video_audio = None + video_reference_mode = str( + resolved_video_conf.get("reference_mode") or "frame" + ) + resolved_video_options = resolve_video_options(normalized_data) + if ( + isinstance(resolved_video_options, tuple) + and len(resolved_video_options) == 3 + ): + generate_audio, negative_prompt, requested_reference_mode = ( + resolved_video_options + ) + if "reference_mode" not in (video_conf or {}): + video_reference_mode = requested_reference_mode + else: + generate_audio, negative_prompt = resolved_video_options + video_locale, timeline_events, video_audio = _resolve_sora_video_extras( + normalized_data + ) + + try: + input_images = load_input_images(normalized_data.get("messages") or []) + set_request_task_progress( + request, task_status="IN_PROGRESS", task_progress=0.0 + ) + + def _run_once(token: str): + source_image_ids: list[str] = [] + if ( + video_engine == "veo31-standard" + and video_reference_mode == "image" + ): + max_video_inputs = 3 + else: + max_video_inputs = ( + 2 if video_engine in {"veo31-fast", "veo31-standard"} else 1 + ) + if len(input_images) > max_video_inputs: + raise HTTPException( + status_code=400, + detail=f"video model supports at most {max_video_inputs} input image(s)", + ) + for image_bytes, _image_mime in input_images[:max_video_inputs]: + prepared_bytes, prepared_mime = prepare_video_source_image( + image_bytes, + ratio, + video_resolution, + ) + source_image_ids.append( + client.upload_image(token, prepared_bytes, prepared_mime) + ) + + def _video_progress_cb(update: dict): + set_request_task_progress( + request, + task_status=str(update.get("task_status") or "IN_PROGRESS"), + task_progress=update.get("task_progress"), + upstream_job_id=update.get("upstream_job_id"), + retry_after=update.get("retry_after"), + error=update.get("error"), + ) + + imgbed_upload_enabled = bool(use_imgbed_upload()) + direct_result_url = ( + bool(use_upstream_result_url()) or imgbed_upload_enabled + ) + task_id = uuid.uuid4().hex + tmp_path = generated_dir / f"{task_id}.video.tmp" + old_size = 0 + if not direct_result_url: + try: + if tmp_path.exists(): + old_size = int(tmp_path.stat().st_size) + except Exception: + old_size = 0 + + video_bytes, video_meta = client.generate_video( + token=token, + video_conf=resolved_video_conf or {}, + prompt=prompt, + aspect_ratio=ratio, + duration=duration, + source_image_ids=source_image_ids, + timeout=max(int(client.generate_timeout), 600), + negative_prompt=negative_prompt, + generate_audio=generate_audio, + locale=video_locale, + timeline_events=timeline_events, + audio=video_audio, + reference_mode=video_reference_mode, + out_path=None if direct_result_url else tmp_path, + progress_cb=_video_progress_cb, + return_upstream_url=direct_result_url, + ) + upstream_video_url = _extract_upstream_asset_url(video_meta, "video") + video_ext = video_ext_from_meta(video_meta) + if imgbed_upload_enabled: + if not upstream_video_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + video_url = upload_generated_asset_to_imgbed( + upstream_video_url, + filename=f"{task_id}.{video_ext}", + mime_type=_video_mime_type(video_ext), + ) + elif direct_result_url: + video_url = upstream_video_url + if not video_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + filename = f"{task_id}.{video_ext}" + out_path = generated_dir / filename + if video_bytes is not None: + out_path.write_bytes(video_bytes) + elif tmp_path.exists(): + tmp_path.replace(out_path) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + video_url = public_generated_url(request, filename) + + set_request_preview(request, video_url, kind="video") + created_ts = int(time.time()) + return { + "id": f"vidgen-{task_id[:24]}", + "object": "video.generation", + "created": created_ts, + "model": resolved_model_id, + "status": "completed", + "task_id": task_id, + "url": video_url, + "video_url": video_url, + "data": [{"url": video_url}], + } + + result = run_with_token_retries( + request=request, + operation_name=operation_name, + run_once=_run_once, + ) + if response_mode == "video": + return result + + video_url = str(result.get("url") or "").strip() + response_payload = { + "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", + "object": "chat.completion", + "created": int(result.get("created") or time.time()), + "model": str(result.get("model") or resolved_model_id), + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": f"```html\n\n```", + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } + if bool(normalized_data.get("stream", False)): + return StreamingResponse( + sse_chat_stream(response_payload), + media_type="text/event-stream", + ) + return response_payload + except quota_error_cls: + error_code = str( + getattr(request.state, "log_error_code", "") or "" + ) or set_request_error_detail( + request, + error="Token quota exhausted", + status_code=429, + error_type="rate_limit_error", + include_traceback=False, + ) + set_request_task_progress( + request, + task_status="FAILED", + task_progress=0.0, + error="Token quota exhausted", + ) + return _json_response( + status_code=429, + content={ + "error": { + "message": "Token quota exhausted", + "type": "rate_limit_error", + "code": error_code, + } + }, + request=request, + ) + except auth_error_cls: + error_code = str( + getattr(request.state, "log_error_code", "") or "" + ) or set_request_error_detail( + request, + error="Token invalid or expired", + status_code=401, + error_type="authentication_error", + include_traceback=False, + ) + set_request_task_progress( + request, + task_status="FAILED", + task_progress=0.0, + error="Token invalid or expired", + ) + return _json_response( + status_code=401, + content={ + "error": { + "message": "Token invalid or expired", + "type": "authentication_error", + "code": error_code, + } + }, + request=request, + ) + except upstream_temp_error_cls as exc: + error_code = str( + getattr(request.state, "log_error_code", "") or "" + ) or set_request_error_detail( + request, + error=exc, + status_code=503, + error_type="server_error", + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=str(exc) + ) + return _json_response( + status_code=503, + content={ + "error": { + "message": str(exc), + "type": "server_error", + "code": error_code, + } + }, + request=request, + ) + except HTTPException as exc: + err_type = ( + "invalid_request_error" + if 400 <= int(exc.status_code) < 500 + else "server_error" + ) + error_code = set_request_error_detail( + request, + error=str(exc.detail), + status_code=exc.status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=str(exc.detail) + ) + return _json_response( + status_code=exc.status_code, + content={ + "error": { + "message": str(exc.detail), + "type": err_type, + "code": error_code, + } + }, + request=request, + ) + except Exception as exc: + normalized = _normalize_upstream_request_error(exc) + if normalized is not None: + status_code, err_type, message = normalized + error_code = set_request_error_detail( + request, + error=message, + status_code=status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=message + ) + return _json_response( + status_code=status_code, + content={ + "error": { + "message": message, + "type": err_type, + "code": error_code, + } + }, + request=request, + ) + error_code = set_request_error_detail( + request, + error=exc, + status_code=500, + error_type="server_error", + include_traceback=True, + ) + logger.exception( + "Unhandled error in %s log_id=%s model=%s resolved_model=%s is_video_model=%s", + route_path, + getattr(request.state, "log_id", ""), + model_id, + resolved_model_id, + True, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=str(exc) + ) + return _json_response( + status_code=500, + content={ + "error": { + "message": str(exc), + "type": "server_error", + "code": error_code, + } + }, + request=request, + ) + + @router.post("/v1/video/generations") + def video_generations(data: dict, request: Request): + require_service_api_key(request) + + prompt = extract_prompt_from_messages(data.get("messages") or []) + if not prompt: + prompt = str(data.get("prompt") or "").strip() + if not prompt: + return _json_response( + status_code=400, + content={ + "error": { + "message": "messages or prompt is required", + "type": "invalid_request_error", + } + }, + request=request, + ) + _validate_prompt_length(prompt) + if _wants_async_video_generation(data): + return _create_async_video_generation(data, request, prompt) + return _handle_video_generation_request( + data, + request, + prompt=prompt, + response_mode="video", + ) + + @router.get("/v1/video/generations/{task_id}") + def get_video_generation(task_id: str, request: Request): + require_service_api_key(request) + + job = store.get(task_id) + if not job or str(getattr(job, "kind", "") or "") != "video": + raise HTTPException(status_code=404, detail="video generation not found") + return _format_video_generation_job(job) + + @router.post("/v1/chat/completions") + def chat_completions(data: dict, request: Request): + require_service_api_key(request) + + prompt = extract_prompt_from_messages(data.get("messages") or []) + if not prompt: + prompt = str(data.get("prompt") or "").strip() + if not prompt: + return _json_response( + status_code=400, + content={ + "error": { + "message": "messages or prompt is required", + "type": "invalid_request_error", + } + }, + request=request, + ) + + model_id = str(data.get("model") or "").strip() + if _looks_like_video_model_id(model_id) and model_id not in video_model_catalog: + return _json_response( + status_code=400, + content={ + "error": { + "message": "Invalid video model. Use /v1/models to get supported sora2, sora2-pro, veo31, veo31-ref or veo31-fast models, then pass duration/aspect_ratio/resolution/reference_mode in the request body.", + "type": "invalid_request_error", + } + }, + request=request, + ) + if model_id in video_model_catalog: + return _handle_video_generation_request( + data, + request, + prompt=prompt, + response_mode="chat", + ) + + _validate_prompt_length(prompt) + ratio, output_resolution, resolved_model_id = resolve_ratio_and_resolution( + data, model_id or None + ) + image_model_conf = resolve_model(resolved_model_id) + + try: + input_images = load_input_images(data.get("messages") or []) + set_request_task_progress( + request, task_status="IN_PROGRESS", task_progress=0.0 + ) + + def _run_once(token: str): + source_image_ids: list[str] = [] + image_url = "" + for image_bytes, image_mime in input_images: + source_image_ids.append( + client.upload_image( + token, image_bytes, image_mime or "image/jpeg" + ) + ) + + def _image_progress_cb(update: dict): + set_request_task_progress( + request, + task_status=str(update.get("task_status") or "IN_PROGRESS"), + task_progress=update.get("task_progress"), + upstream_job_id=update.get("upstream_job_id"), + retry_after=update.get("retry_after"), + error=update.get("error"), + ) + + imgbed_upload_enabled = bool(use_imgbed_upload()) + direct_result_url = bool(use_upstream_result_url()) or imgbed_upload_enabled + job_id = uuid.uuid4().hex + out_path = generated_dir / f"{job_id}.png" + old_size = 0 + if not direct_result_url: + try: + if out_path.exists(): + old_size = int(out_path.stat().st_size) + except Exception: + old_size = 0 + + image_bytes, meta = client.generate( + token=token, + prompt=prompt, + aspect_ratio=ratio, + output_resolution=output_resolution, + upstream_model_id=str( + image_model_conf.get("upstream_model_id") or "gemini-flash" + ), + upstream_model_version=str( + image_model_conf.get("upstream_model_version") + or "nano-banana-2" + ), + source_image_ids=source_image_ids, + timeout=client.generate_timeout, + out_path=None if direct_result_url else out_path, + progress_cb=_image_progress_cb, + return_upstream_url=direct_result_url, + ) + upstream_image_url = _extract_upstream_asset_url(meta, "image") + if imgbed_upload_enabled: + if not upstream_image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + image_url = upload_generated_asset_to_imgbed( + upstream_image_url, + filename=f"{job_id}.png", + mime_type="image/png", + ) + elif direct_result_url: + image_url = upstream_image_url + if not image_url: + raise HTTPException( + status_code=502, + detail="upstream result url missing", + ) + else: + if image_bytes is not None: + out_path.write_bytes(image_bytes) + new_size = int(out_path.stat().st_size) if out_path.exists() else 0 + on_generated_file_written(out_path, old_size, new_size) + image_url = public_image_url(request, job_id) + set_request_preview(request, image_url, kind="image") + response_content = f"![Generated Image]({image_url})" + + response_payload = { + "id": f"chatcmpl-{uuid.uuid4().hex[:24]}", + "object": "chat.completion", + "created": int(time.time()), + "model": resolved_model_id, + "choices": [ + { + "index": 0, + "message": { + "role": "assistant", + "content": response_content, + }, + "finish_reason": "stop", + } + ], + "usage": { + "prompt_tokens": 0, + "completion_tokens": 0, + "total_tokens": 0, + }, + } + if bool(data.get("stream", False)): + return StreamingResponse( + sse_chat_stream(response_payload), + media_type="text/event-stream", + ) + return response_payload + + return run_with_token_retries( request=request, operation_name="chat.completions", run_once=_run_once, @@ -650,7 +1708,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token quota exhausted", ) - return JSONResponse( + return _json_response( status_code=429, content={ "error": { @@ -659,6 +1717,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except auth_error_cls: error_code = str( @@ -676,7 +1735,7 @@ def _image_progress_cb(update: dict): task_progress=0.0, error="Token invalid or expired", ) - return JSONResponse( + return _json_response( status_code=401, content={ "error": { @@ -685,6 +1744,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except upstream_temp_error_cls as exc: error_code = str( @@ -699,7 +1759,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=503, content={ "error": { @@ -708,6 +1768,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except HTTPException as exc: err_type = ( @@ -725,7 +1786,7 @@ def _image_progress_cb(update: dict): set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc.detail) ) - return JSONResponse( + return _json_response( status_code=exc.status_code, content={ "error": { @@ -734,8 +1795,33 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) except Exception as exc: + normalized = _normalize_upstream_request_error(exc) + if normalized is not None: + status_code, err_type, message = normalized + error_code = set_request_error_detail( + request, + error=message, + status_code=status_code, + error_type=err_type, + include_traceback=False, + ) + set_request_task_progress( + request, task_status="FAILED", task_progress=0.0, error=message + ) + return _json_response( + status_code=status_code, + content={ + "error": { + "message": message, + "type": err_type, + "code": error_code, + } + }, + request=request, + ) error_code = set_request_error_detail( request, error=exc, @@ -744,16 +1830,15 @@ def _image_progress_cb(update: dict): include_traceback=True, ) logger.exception( - "Unhandled error in /v1/chat/completions log_id=%s model=%s resolved_model=%s is_video_model=%s", + "Unhandled error in /v1/chat/completions log_id=%s model=%s resolved_model=%s", getattr(request.state, "log_id", ""), model_id, resolved_model_id, - is_video_model, ) set_request_task_progress( request, task_status="FAILED", task_progress=0.0, error=str(exc) ) - return JSONResponse( + return _json_response( status_code=500, content={ "error": { @@ -762,6 +1847,7 @@ def _image_progress_cb(update: dict): "code": error_code, } }, + request=request, ) return router diff --git a/api/schemas.py b/api/schemas.py index db624c7..6aed0ba 100644 --- a/api/schemas.py +++ b/api/schemas.py @@ -33,6 +33,8 @@ class ConfigUpdateRequest(BaseModel): public_base_url: Optional[str] = None proxy: Optional[str] = None use_proxy: Optional[bool] = None + resource_proxy: Optional[str] = None + resource_use_proxy: Optional[bool] = None generate_timeout: Optional[int] = None refresh_interval_hours: Optional[int] = None retry_enabled: Optional[bool] = None @@ -44,6 +46,10 @@ class ConfigUpdateRequest(BaseModel): batch_concurrency: Optional[int] = None generated_max_size_mb: Optional[int] = None generated_prune_size_mb: Optional[int] = None + use_upstream_result_url: Optional[bool] = None + imgbed_enabled: Optional[bool] = None + imgbed_api_url: Optional[str] = None + imgbed_api_key: Optional[str] = None class RefreshCookieImportRequest(BaseModel): @@ -67,3 +73,10 @@ class RefreshProfileEnabledRequest(BaseModel): class AdminLoginRequest(BaseModel): username: str password: str + + +class ProxyTestRequest(BaseModel): + proxy: Optional[str] = None + use_proxy: Optional[bool] = None + resource_proxy: Optional[str] = None + resource_use_proxy: Optional[bool] = None diff --git a/app.py b/app.py index d581f0f..15fb355 100644 --- a/app.py +++ b/app.py @@ -37,6 +37,9 @@ from core.token_mgr import token_manager from core.config_mgr import config_manager from core.refresh_mgr import refresh_manager +from core.imgbed_client import ImgBedClient +from core.proxy_utils import build_requests_proxies, resolve_resource_proxy +from core.redis_health import check_redis_connection from core.stores import ( ErrorDetailRecord, ErrorDetailStore, @@ -55,6 +58,7 @@ logger = logging.getLogger("adobe2api") +imgbed_client = ImgBedClient() BASE_DIR = Path(__file__).resolve().parent @@ -69,8 +73,6 @@ _generated_usage_bytes = 0 _generated_file_count = 0 _generated_last_reconcile_ts = 0.0 - - def _drop_generated_file_cache(file_path: Path) -> None: if not hasattr(os, "posix_fadvise"): return @@ -126,15 +128,83 @@ def serve_generated_file(filename: str): refresh_manager.start() +def _extract_model_params(data: dict[str, Any]) -> Optional[str]: + if not isinstance(data, dict): + return None + + def _pick(*keys: str) -> str: + for key in keys: + value = data.get(key) + if value is None: + continue + text = str(value).strip() + if text: + return text + return "" + + parts: list[str] = [] + duration = _pick("duration", "video_duration", "videoDuration") + if duration: + duration_text = duration.rstrip("sS") + if duration_text: + parts.append(f"{duration_text}s") + + aspect_ratio = _pick("aspect_ratio", "aspectRatio") + if aspect_ratio: + parts.append(aspect_ratio) + + resolution = _pick("resolution", "video_resolution", "videoResolution") + if resolution: + parts.append(resolution) + else: + output_resolution = _pick("output_resolution", "outputResolution") + if output_resolution: + parts.append(output_resolution) + + size_val = data.get("size") + if size_val is not None: + if isinstance(size_val, str): + size_text = size_val.strip() + elif isinstance(size_val, dict): + width = str(size_val.get("width") or "").strip() + height = str(size_val.get("height") or "").strip() + size_text = f"{width}x{height}" if width and height else "" + else: + size_text = "" + if size_text and size_text not in parts: + parts.append(size_text) + + reference_mode = _pick( + "reference_mode", + "referenceMode", + "video_reference_mode", + "videoReferenceMode", + ) + if reference_mode: + parts.append(f"ref:{reference_mode}") + + if not parts: + return None + return " | ".join(parts[:5]) + + def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if not raw_body: - return {"model": None, "prompt_preview": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + } try: import json data: Any = json.loads(raw_body.decode("utf-8")) if not isinstance(data, dict): - return {"model": None, "prompt_preview": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + } model = str(data.get("model") or "").strip() or None prompt = str(data.get("prompt") or "").strip() @@ -143,9 +213,17 @@ def _extract_logging_fields(raw_body: bytes) -> dict[str, Optional[str]]: if prompt: prompt = prompt.replace("\r", " ").replace("\n", " ").strip() prompt = prompt[:180] - return {"model": model, "prompt_preview": prompt or None} + return { + "model": model, + "model_params": _extract_model_params(data), + "prompt_preview": prompt or None, + } except Exception: - return {"model": None, "prompt_preview": None} + return { + "model": None, + "model_params": None, + "prompt_preview": None, + } def _upsert_live_request(request: Request, patch: dict) -> None: @@ -204,6 +282,7 @@ def _set_request_error_detail( op_map = { "/v1/chat/completions": "chat.completions", "/v1/images/generations": "images.generations", + "/v1/video/generations": "video.generations", "/api/v1/generate": "api.generate", } path = str(getattr(getattr(request, "url", None), "path", "") or "") @@ -300,6 +379,7 @@ def _set_request_task_progress( "error": patch.get("error"), "error_code": getattr(request.state, "log_error_code", None), "model": getattr(request.state, "log_model", None), + "model_params": getattr(request.state, "log_model_params", None), "prompt_preview": getattr(request.state, "log_prompt_preview", None), "ts": time.time(), }, @@ -353,6 +433,7 @@ def _append_attempt_log( method = str(getattr(request, "method", "POST") or "POST").upper() path = str(getattr(getattr(request, "url", None), "path", "") or "") model = getattr(request.state, "log_model", None) + model_params = getattr(request.state, "log_model_params", None) prompt_preview = getattr(request.state, "log_prompt_preview", None) preview_url = getattr(request.state, "log_preview_url", None) preview_kind = getattr(request.state, "log_preview_kind", None) @@ -373,9 +454,11 @@ def _append_attempt_log( status_code=int(status_code), duration_sec=duration_sec, operation=operation, + request_id=root_log_id, preview_url=preview_url, preview_kind=preview_kind, model=model, + model_params=model_params, prompt_preview=prompt_preview, error=(str(error)[:240] if error else None), error_code=(str(error_code or "") or None), @@ -412,13 +495,18 @@ async def request_logger(request: Request, call_next): preview_url = None preview_kind = None raw_body = b"" - body_meta = {"model": None, "prompt_preview": None} + body_meta = { + "model": None, + "model_params": None, + "prompt_preview": None, + } error_text = None status_code = 500 op_map = { "/v1/chat/completions": "chat.completions", "/v1/images/generations": "images.generations", + "/v1/video/generations": "video.generations", } operation = op_map.get(path, "") should_log = bool(operation) @@ -430,10 +518,12 @@ async def request_logger(request: Request, call_next): if path in { "/v1/images/generations", "/v1/chat/completions", + "/v1/video/generations", "/api/v1/generate", }: body_meta = _extract_logging_fields(raw_body) request.state.log_model = body_meta.get("model") + request.state.log_model_params = body_meta.get("model_params") request.state.log_prompt_preview = body_meta.get("prompt_preview") request.state.log_id = uuid.uuid4().hex[:12] log_id = str(getattr(request.state, "log_id", "") or "") @@ -449,6 +539,7 @@ async def request_logger(request: Request, call_next): "duration_sec": 0, "operation": operation, "model": body_meta.get("model"), + "model_params": body_meta.get("model_params"), "prompt_preview": body_meta.get("prompt_preview"), "task_status": "IN_PROGRESS", "task_progress": 0.0, @@ -539,9 +630,11 @@ async def request_logger(request: Request, call_next): status_code=status_code, duration_sec=duration_sec, operation=operation, + request_id=log_id, preview_url=preview_url, preview_kind=preview_kind, model=body_meta.get("model"), + model_params=body_meta.get("model_params"), prompt_preview=body_meta.get("prompt_preview"), error=error_final, error_code=error_code, @@ -614,7 +707,10 @@ def _run_with_token_retries( except QuotaExhaustedError as exc: token_manager.report_exhausted(token) last_exc = exc - retryable = attempt < max_attempts + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = attempt < max_attempts and not upstream_job_created retry_reason = "quota_exhausted" err_code = report_error( request, @@ -637,7 +733,10 @@ def _run_with_token_retries( except AuthError as exc: token_manager.report_invalid(token) last_exc = exc - retryable = attempt < max_attempts + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = attempt < max_attempts and not upstream_job_created retry_reason = "auth" err_code = report_error( request, @@ -659,8 +758,13 @@ def _run_with_token_retries( ) except UpstreamTemporaryError as exc: last_exc = exc - retryable = attempt < max_attempts and client.should_retry_temporary_error( - exc + upstream_job_created = bool( + str(getattr(request.state, "log_upstream_job_id", "") or "").strip() + ) + retryable = ( + attempt < max_attempts + and client.should_retry_temporary_error(exc) + and not upstream_job_created ) status_part = f"status={exc.status_code}" if exc.status_code else "status=?" type_part = f"type={exc.error_type or 'temporary'}" @@ -863,7 +967,11 @@ def _load_input_images(messages) -> list[tuple[bytes, str]]: status_code=400, detail="Only http/https or data URL images are supported", ) - resp = requests.get(image_url, timeout=30) + resp = requests.get( + image_url, + timeout=30, + proxies=build_requests_proxies(resolve_resource_proxy(config_manager.get_all())), + ) if resp.status_code != 200: raise HTTPException( status_code=400, @@ -963,12 +1071,83 @@ def _require_admin_auth(request: Request) -> None: def _apply_client_config() -> None: client.apply_config(config_manager.get_all()) + imgbed_client.apply_config(config_manager.get_all()) + + +_apply_client_config() + +_redis_health_lock = threading.Lock() +_REDIS_HEALTH_CACHE_TTL_SEC = 30 +_redis_health_state: dict[str, Any] = { + "configured": False, + "ok": False, + "host": None, + "port": None, + "db": None, + "ssl": False, + "checked_at": None, + "error": "redis not checked yet", +} + + +def _refresh_redis_health() -> dict[str, Any]: + health = check_redis_connection() + with _redis_health_lock: + _redis_health_state.clear() + _redis_health_state.update(health) + return dict(health) + + +def _get_redis_health() -> dict[str, Any]: + with _redis_health_lock: + checked_at = _redis_health_state.get("checked_at") + if checked_at and (time.time() - float(checked_at)) < _REDIS_HEALTH_CACHE_TTL_SEC: + return dict(_redis_health_state) + return _refresh_redis_health() + + +@app.on_event("startup") +def _startup_connectivity_checks() -> None: + redis_health = _refresh_redis_health() + if not redis_health.get("configured"): + logger.info("redis check skipped: %s", redis_health.get("error")) + return + if redis_health.get("ok"): + logger.info( + "redis connected host=%s port=%s db=%s ssl=%s", + redis_health.get("host"), + redis_health.get("port"), + redis_health.get("db"), + redis_health.get("ssl"), + ) + else: + logger.warning( + "redis connection failed host=%s port=%s db=%s error=%s", + redis_health.get("host"), + redis_health.get("port"), + redis_health.get("db"), + redis_health.get("error"), + ) def _public_image_url(request: Request, job_id: str) -> str: return _public_generated_url(request, f"{job_id}.png") +def _use_upstream_result_url() -> bool: + return bool(config_manager.get("use_upstream_result_url", False)) + + +def _use_imgbed_upload() -> bool: + return bool(config_manager.get("imgbed_enabled", False)) + + +def _upload_generated_asset_to_imgbed( + source_url: str, filename: str, mime_type: str | None = None +) -> str: + return imgbed_client.upload_from_url(source_url, filename=filename, mime_type=mime_type) + + def _public_generated_url(request: Request, filename: str) -> str: safe_name = str(filename or "").lstrip("/") path = f"/generated/{safe_name}" @@ -1204,12 +1383,15 @@ def _sse_chat_stream(payload: dict): is_admin_authenticated=_is_admin_authenticated, apply_client_config=_apply_client_config, get_generated_storage_stats=_get_generated_storage_stats, + get_redis_health=_get_redis_health, ) ) app.include_router( build_generation_router( store=store, + request_log_store=log_store, + live_request_store=live_log_store, token_manager=token_manager, client=client, generated_dir=GENERATED_DIR, @@ -1225,6 +1407,9 @@ def _sse_chat_stream(payload: dict): set_request_preview=_set_request_preview, public_image_url=_public_image_url, public_generated_url=_public_generated_url, + use_upstream_result_url=_use_upstream_result_url, + use_imgbed_upload=_use_imgbed_upload, + upload_generated_asset_to_imgbed=_upload_generated_asset_to_imgbed, resolve_video_options=_resolve_video_options, load_input_images=_load_input_images, prepare_video_source_image=_prepare_video_source_image, diff --git a/browser-cookie-exporter/README.md b/browser-cookie-exporter/README.md index 1e8d2c0..a2d46d2 100644 --- a/browser-cookie-exporter/README.md +++ b/browser-cookie-exporter/README.md @@ -1,16 +1,9 @@ -# Adobe Cookie Exporter 插件 +# Adobe Cookie Exporter -一个 Chrome/Edge(Manifest V3)插件,用于导出 Adobe/Firefly Cookie。 -当前改为仅导出 `adobe2api` 导入所需最小字段。 +A small Chrome or Edge extension used to export Adobe or Firefly cookies in the +minimal JSON format required by `adobe2api`. -插件界面仅保留: - -- 导出范围 -- 导出最简 JSON - -## 导出格式 - -导出的 JSON 结构如下(最简): +## Export Format ```json { @@ -18,31 +11,40 @@ } ``` -## 安装方式(开发者模式) - -1. 打开 Chrome/Edge 扩展页面:`chrome://extensions` 或 `edge://extensions` -2. 开启「开发者模式」 -3. 点击「加载已解压的扩展程序」 -4. 选择目录:`browser-cookie-exporter/` +## Install -## 使用说明 +1. Open `chrome://extensions` or `edge://extensions` +2. Enable developer mode +3. Click `Load unpacked` +4. Select the `browser-cookie-exporter/` folder -1. 先在浏览器登录 Adobe/Firefly -2. 点击插件图标 -3. 选择导出范围: - - `Adobe 全域(推荐)`:读取 `*.adobe.com` 相关 Cookie - - `当前站点`:仅读取当前标签页站点 Cookie -4. 可选填写账号标识(用于文件名和 JSON 的 `email` 字段) -5. 点击 `导出 JSON` +## Usage -## 与 adobe2api 联动 +1. Log in to Adobe or Firefly +2. Open the extension popup +3. Choose an export scope: + - `Adobe domains (recommended)` + - `Current site` +4. Click `Export Minimal JSON` -可直接把导出的 JSON 传给 `adobe2api` 的导入接口: +## Import Into adobe2api ```bash curl -X POST "http://127.0.0.1:6001/api/v1/refresh-profiles/import-cookie" \ -H "Content-Type: application/json" \ - -d '{"name":"my-account","cookie": <导出的整个JSON或cookie_header字符串>}' + -d '{"name":"my-account","cookie":"k1=v1; k2=v2"}' ``` -说明:导出文件名格式为 `cookie_YYYYMMDD_HHMMSS.json`。 +## Incognito Support + +The extension exports cookies from the cookie store used by the active tab. +If you open the popup from an incognito Adobe or Firefly tab, the exported JSON +will contain the incognito cookie jar instead of the regular browser cookie jar. + +To use it in incognito: + +1. Open `chrome://extensions` or `edge://extensions` +2. Open this extension's details page +3. Enable `Allow in Incognito` +4. Open Adobe or Firefly in an incognito window +5. Open the popup from that incognito tab and export the JSON diff --git a/browser-cookie-exporter/manifest.json b/browser-cookie-exporter/manifest.json index ea61910..6fdbca4 100644 --- a/browser-cookie-exporter/manifest.json +++ b/browser-cookie-exporter/manifest.json @@ -3,6 +3,7 @@ "name": "Adobe Cookie Exporter", "description": "Export Adobe/Firefly cookies in adobe_register-compatible JSON format.", "version": "1.0.0", + "incognito": "split", "permissions": [ "cookies", "downloads", diff --git a/browser-cookie-exporter/popup.css b/browser-cookie-exporter/popup.css index 65b85dd..dde6e82 100644 --- a/browser-cookie-exporter/popup.css +++ b/browser-cookie-exporter/popup.css @@ -9,7 +9,7 @@ body { } .app { - width: 300px; + width: 320px; padding: 14px; display: grid; gap: 10px; @@ -21,6 +21,12 @@ h1 { color: #1f2937; } +.context { + margin: 0; + font-size: 12px; + color: #475569; +} + .field { display: grid; gap: 6px; @@ -33,7 +39,6 @@ button { font: inherit; } -select, select { border: 1px solid #cbd5e1; border-radius: 8px; @@ -56,10 +61,6 @@ button { font-weight: 600; } -button:last-child { - background: #0f766e; -} - .result { display: grid; gap: 6px; diff --git a/browser-cookie-exporter/popup.html b/browser-cookie-exporter/popup.html index 81e5caf..447edb1 100644 --- a/browser-cookie-exporter/popup.html +++ b/browser-cookie-exporter/popup.html @@ -8,22 +8,23 @@
-

Adobe Cookie 导出

+

Adobe Cookie Export

+

Checking browser context...

- +
-

等待导出…

+

Ready to export.

diff --git a/browser-cookie-exporter/popup.js b/browser-cookie-exporter/popup.js index f84c9ed..736c0b9 100644 --- a/browser-cookie-exporter/popup.js +++ b/browser-cookie-exporter/popup.js @@ -1,4 +1,5 @@ const statusText = document.getElementById("statusText"); +const contextText = document.getElementById("contextText"); const scopeSelect = document.getElementById("scopeSelect"); const exportJsonBtn = document.getElementById("exportJsonBtn"); @@ -30,9 +31,46 @@ function getCurrentTab() { }); } -function getCookies(filter) { +function getAllCookieStores() { return new Promise((resolve, reject) => { - chrome.cookies.getAll(filter, (cookies) => { + chrome.cookies.getAllCookieStores((stores) => { + if (chrome.runtime.lastError) { + reject(new Error(chrome.runtime.lastError.message)); + return; + } + resolve(Array.isArray(stores) ? stores : []); + }); + }); +} + +async function getCurrentContext() { + const tab = await getCurrentTab(); + if (!tab || typeof tab.id !== "number") { + throw new Error("Unable to find the active tab for cookie export."); + } + + const stores = await getAllCookieStores(); + const matchedStore = stores.find((store) => + Array.isArray(store.tabIds) && store.tabIds.includes(tab.id) + ); + if (!matchedStore || !matchedStore.id) { + throw new Error("Unable to resolve the cookie store for the active tab."); + } + + return { + tab, + storeId: matchedStore.id, + incognito: Boolean(tab.incognito || chrome.extension.inIncognitoContext) + }; +} + +function getCookies(filter, storeId) { + return new Promise((resolve, reject) => { + const nextFilter = { ...(filter || {}) }; + if (storeId) { + nextFilter.storeId = storeId; + } + chrome.cookies.getAll(nextFilter, (cookies) => { if (chrome.runtime.lastError) { reject(new Error(chrome.runtime.lastError.message)); return; @@ -43,20 +81,22 @@ function getCookies(filter) { } async function collectCookiesByScope(scope) { + const context = await getCurrentContext(); + const { tab, storeId, incognito } = context; + if (scope === "current") { - const tab = await getCurrentTab(); const url = tab && tab.url ? tab.url : ""; if (!url.startsWith("http://") && !url.startsWith("https://")) { - throw new Error("当前标签页不是网页,无法按当前站点读取 Cookie"); + throw new Error("The current tab is not a regular web page."); } - const cookies = await getCookies({ url }); - return { cookies, sourceUrl: url }; + const cookies = await getCookies({ url }, storeId); + return { cookies, sourceUrl: url, storeId, incognito }; } const domains = [".adobe.com", "firefly.adobe.com", "account.adobe.com"]; const all = []; for (const domain of domains) { - const cookies = await getCookies({ domain }); + const cookies = await getCookies({ domain }, storeId); all.push(...cookies); } @@ -68,7 +108,13 @@ async function collectCookiesByScope(scope) { seen.add(key); unique.push(item); } - return { cookies: unique, sourceUrl: "https://firefly.adobe.com/" }; + + return { + cookies: unique, + sourceUrl: "https://firefly.adobe.com/", + storeId, + incognito + }; } function toPlaywrightLikeCookies(cookies) { @@ -107,34 +153,58 @@ function downloadJson(filename, data) { async function generatePayload() { const scope = scopeSelect.value; - const { cookies } = await collectCookiesByScope(scope); + const { cookies, incognito, storeId } = await collectCookiesByScope(scope); const normalizedCookies = toPlaywrightLikeCookies(cookies); const cookieHeader = buildCookieHeader(normalizedCookies); const now = new Date(); const fileTs = toTimestampParts(now); const payload = { cookie: cookieHeader }; - const fileName = `cookie_${fileTs}.json`; return { payload, fileName, cookieCount: normalizedCookies.length, - cookieHeader + incognito, + storeId }; } +function renderContext(context) { + const modeText = context.incognito ? "Incognito" : "Regular"; + contextText.textContent = `Browser context: ${modeText} window | store: ${context.storeId}`; + if (context.incognito) { + setStatus("Incognito cookie store detected. Export will use the isolated incognito cookie jar."); + } else { + setStatus("Regular browser context detected."); + } +} + +async function initContext() { + try { + const context = await getCurrentContext(); + renderContext(context); + } catch (error) { + contextText.textContent = "Browser context: unavailable"; + setStatus(`Unable to detect the cookie store: ${error.message || error}`); + exportJsonBtn.disabled = true; + } +} + exportJsonBtn.addEventListener("click", async () => { try { - setStatus("正在读取 Cookie..."); - const { payload, fileName, cookieCount, cookieHeader } = await generatePayload(); + setStatus("Reading cookies..."); + const { payload, fileName, cookieCount, incognito } = await generatePayload(); if (!cookieCount) { - setStatus("未读取到 Cookie,请先登录 Adobe/Firefly 后重试"); + setStatus("No cookies were found. Log in to Adobe or Firefly first."); return; } downloadJson(fileName, payload); - setStatus(`导出成功:${cookieCount} 条 Cookie`); + const modeText = incognito ? "incognito" : "regular"; + setStatus(`Exported ${cookieCount} cookies from the ${modeText} browser store.`); } catch (error) { - setStatus(`导出失败:${error.message || error}`); + setStatus(`Export failed: ${error.message || error}`); } }); + +initContext(); diff --git a/core/adobe_client.py b/core/adobe_client.py index 27fb1df..22303d4 100644 --- a/core/adobe_client.py +++ b/core/adobe_client.py @@ -10,6 +10,7 @@ from core.config_mgr import config_manager from core.models import build_image_payload_candidates +from core.proxy_utils import build_requests_proxies, resolve_basic_proxy, resolve_resource_proxy try: from curl_cffi.requests import Session as CurlSession @@ -52,7 +53,8 @@ class AdobeClient: def __init__(self) -> None: self.api_key = "clio-playground-web" self.impersonate = "chrome124" - self.proxy = "" + self.basic_proxy = "" + self.resource_proxy = "" self.generate_timeout = 300 self.retry_enabled = True self.retry_max_attempts = 3 @@ -70,6 +72,7 @@ def __init__(self) -> None: env_api_key = os.getenv("ADOBE_API_KEY") env_impersonate = os.getenv("ADOBE_IMPERSONATE") env_proxy = os.getenv("ADOBE_PROXY") + env_resource_proxy = os.getenv("ADOBE_RESOURCE_PROXY") env_user_agent = os.getenv("ADOBE_USER_AGENT") env_sec_ch_ua = os.getenv("ADOBE_SEC_CH_UA") env_generate_timeout = os.getenv("ADOBE_GENERATE_TIMEOUT") @@ -79,7 +82,11 @@ def __init__(self) -> None: if env_impersonate: self.impersonate = env_impersonate.strip() or self.impersonate if env_proxy is not None: - self.proxy = env_proxy.strip() + self.basic_proxy = env_proxy.strip() + if env_resource_proxy is not None: + self.resource_proxy = env_resource_proxy.strip() + elif env_proxy is not None: + self.resource_proxy = env_proxy.strip() if env_user_agent: self.user_agent = env_user_agent.strip() or self.user_agent if env_sec_ch_ua: @@ -93,15 +100,14 @@ def __init__(self) -> None: pass def apply_config(self, cfg: dict) -> None: - proxy = str(cfg.get("proxy", "")).strip() - use_proxy = bool(cfg.get("use_proxy", False)) + self.basic_proxy = resolve_basic_proxy(cfg) + self.resource_proxy = resolve_resource_proxy(cfg) timeout_val = cfg.get("generate_timeout", 300) try: timeout_val = int(timeout_val) except Exception: timeout_val = 300 self.generate_timeout = timeout_val if timeout_val > 0 else 300 - self.proxy = proxy if use_proxy and proxy else "" self.retry_enabled = bool(cfg.get("retry_enabled", True)) try: attempts = int(cfg.get("retry_max_attempts", 3)) @@ -159,10 +165,19 @@ def apply_config(self, cfg: dict) -> None: if strategy not in {"round_robin", "random"}: strategy = "round_robin" self.token_rotation_strategy = strategy - if self.proxy: - logger.warning("proxy enabled for upstream requests: %s", self.proxy) + if self.basic_proxy: + logger.warning( + "basic proxy enabled for upstream requests: %s", self.basic_proxy + ) + else: + logger.warning("basic proxy disabled for upstream requests") + if self.resource_proxy: + logger.warning( + "resource proxy enabled for media transfer requests: %s", + self.resource_proxy, + ) else: - logger.warning("proxy disabled for upstream requests") + logger.warning("resource proxy disabled for media transfer requests") def _retry_delay_for_attempt(self, attempt: int) -> float: base = float(self.retry_backoff_seconds or 0.0) @@ -202,17 +217,19 @@ def _classify_network_error_type(exc: Exception) -> str: return "connection" return "network" - def _requests_proxies(self) -> Optional[dict]: - if not self.proxy: - return None - return {"http": self.proxy, "https": self.proxy} + def _requests_proxies(self, proxy_kind: str = "basic") -> Optional[dict]: + proxy = ( + self.resource_proxy if str(proxy_kind).strip().lower() == "resource" else self.basic_proxy + ) + return build_requests_proxies(proxy) - def _session(self): + def _session(self, proxy_kind: str = "basic"): if CurlSession is None: return None kwargs = {"impersonate": self.impersonate, "timeout": 60} - if self.proxy: - kwargs["proxies"] = {"http": self.proxy, "https": self.proxy} + proxies = self._requests_proxies(proxy_kind=proxy_kind) + if proxies: + kwargs["proxies"] = proxies return CurlSession(**kwargs) def _browser_headers(self) -> dict: @@ -259,7 +276,7 @@ def _poll_headers(self, token: str) -> dict: } def _post_json(self, url: str, headers: dict, payload: dict): - session = self._session() + session = self._session(proxy_kind="basic") if session is None: try: return requests.post( @@ -267,7 +284,7 @@ def _post_json(self, url: str, headers: dict, payload: dict): headers=headers, json=payload, timeout=60, - proxies=self._requests_proxies(), + proxies=self._requests_proxies(proxy_kind="basic"), ) except requests.Timeout as exc: raise UpstreamTemporaryError( @@ -300,7 +317,7 @@ def _post_json(self, url: str, headers: dict, payload: dict): headers=headers, json=payload, timeout=60, - proxies=self._requests_proxies(), + proxies=self._requests_proxies(proxy_kind="basic"), ) except requests.Timeout as exc: raise UpstreamTemporaryError( @@ -324,8 +341,8 @@ def _post_json(self, url: str, headers: dict, payload: dict): ) return resp - def _post_bytes(self, url: str, headers: dict, payload: bytes): - session = self._session() + def _post_bytes(self, url: str, headers: dict, payload: bytes, proxy_kind: str = "basic"): + session = self._session(proxy_kind=proxy_kind) if session is None: try: return requests.post( @@ -333,7 +350,7 @@ def _post_bytes(self, url: str, headers: dict, payload: bytes): headers=headers, data=payload, timeout=60, - proxies=self._requests_proxies(), + proxies=self._requests_proxies(proxy_kind=proxy_kind), ) except requests.Timeout as exc: raise UpstreamTemporaryError( @@ -361,15 +378,15 @@ def _post_bytes(self, url: str, headers: dict, payload: bytes): ) return resp - def _get(self, url: str, headers: dict, timeout: int = 60): - session = self._session() + def _get(self, url: str, headers: dict, timeout: int = 60, proxy_kind: str = "basic"): + session = self._session(proxy_kind=proxy_kind) if session is None: try: return requests.get( url, headers=headers, timeout=timeout, - proxies=self._requests_proxies(), + proxies=self._requests_proxies(proxy_kind=proxy_kind), ) except requests.Timeout as exc: raise UpstreamTemporaryError( @@ -412,7 +429,7 @@ def _download_to_file( url, headers=headers or {}, timeout=timeout, - proxies=self._requests_proxies(), + proxies=self._requests_proxies(proxy_kind="resource"), stream=True, ) as resp: resp.raise_for_status() @@ -448,7 +465,12 @@ def upload_image( "content-type": mime_type, "accept": "application/json", } - resp = self._post_bytes(self.upload_url, headers=headers, payload=image_bytes) + resp = self._post_bytes( + self.upload_url, + headers=headers, + payload=image_bytes, + proxy_kind="resource", + ) if resp.status_code in (401, 403): raise AuthError("Token invalid or expired") @@ -623,7 +645,11 @@ def _extract_job_id(raw_url: str) -> str: @staticmethod def _build_video_prompt_json( - prompt: str, duration: int, negative_prompt: str = "" + prompt: str, + duration: int, + negative_prompt: str = "", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, ) -> str: payload = { "id": 1, @@ -632,6 +658,10 @@ def _build_video_prompt_json( } if negative_prompt: payload["negative_prompt"] = negative_prompt + if isinstance(timeline_events, dict) and timeline_events: + payload["timeline_events"] = timeline_events + if isinstance(audio, dict) and audio: + payload["audio"] = audio return json.dumps(payload, ensure_ascii=False) def _build_video_payload( @@ -643,6 +673,9 @@ def _build_video_payload( source_image_ids: Optional[list[str]] = None, negative_prompt: str = "", generate_audio: bool = True, + locale: str = "en-US", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, reference_mode: str = "frame", ) -> dict: seed_val = int(time.time()) % 999999 @@ -703,7 +736,11 @@ def _build_video_payload( "duration": int(duration), "fps": 24, "prompt": self._build_video_prompt_json( - prompt=prompt, duration=duration, negative_prompt=negative_prompt + prompt=prompt, + duration=duration, + negative_prompt=negative_prompt, + timeline_events=timeline_events, + audio=audio, ), "generationMetadata": {"module": "text2video"}, "model": upstream_model, @@ -711,7 +748,7 @@ def _build_video_payload( "generateLoop": False, "transparentBackground": False, "seed": str(seed_val), - "locale": "en-US", + "locale": str(locale or "en-US").strip() or "en-US", "camera": { "angle": "none", "shotSize": "none", @@ -756,9 +793,13 @@ def generate_video( timeout: int = 600, negative_prompt: str = "", generate_audio: bool = True, + locale: str = "en-US", + timeline_events: Optional[dict] = None, + audio: Optional[dict] = None, reference_mode: str = "frame", out_path: Optional[Path] = None, progress_cb: Optional[Callable[[dict], None]] = None, + return_upstream_url: bool = False, ) -> tuple[Optional[bytes], dict]: payload = self._build_video_payload( video_conf=video_conf, @@ -768,6 +809,9 @@ def generate_video( source_image_ids=source_image_ids, negative_prompt=negative_prompt, generate_audio=generate_audio, + locale=locale, + timeline_events=timeline_events, + audio=audio, reference_mode=reference_mode, ) submit_resp = self._post_json( @@ -814,94 +858,137 @@ def generate_video( pass start = time.time() + last_progress = 0.0 + poll_retry_attempt = 0 while True: - poll_resp = self._get( - poll_url, headers=self._poll_headers(token), timeout=60 - ) - if poll_resp.status_code in (401, 403): - raise AuthError("Token invalid or expired") - if poll_resp.status_code != 200: - if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: - raise UpstreamTemporaryError( - f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", - status_code=poll_resp.status_code, - error_type="status", - ) - raise AdobeRequestError( - f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" + try: + poll_resp = self._get( + poll_url, headers=self._poll_headers(token), timeout=60 ) + if poll_resp.status_code in (401, 403): + raise AuthError("Token invalid or expired") + if poll_resp.status_code != 200: + if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: + raise UpstreamTemporaryError( + f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", + status_code=poll_resp.status_code, + error_type="status", + ) + raise AdobeRequestError( + f"video poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" + ) - latest = poll_resp.json() - status_header = str(poll_resp.headers.get("x-task-status") or "").upper() - status_val = str(latest.get("status") or "").upper() or status_header - progress_val = self._extract_progress_percent(latest, poll_resp) + latest = poll_resp.json() + status_header = str(poll_resp.headers.get("x-task-status") or "").upper() + status_val = str(latest.get("status") or "").upper() or status_header + progress_val = self._extract_progress_percent(latest, poll_resp) + if progress_val is not None: + last_progress = progress_val + poll_retry_attempt = 0 - if progress_cb and self._is_in_progress_status(status_val): - try: - progress_cb( - { - "task_status": "IN_PROGRESS", - "task_progress": progress_val - if progress_val is not None - else 0.0, - "upstream_job_id": upstream_job_id, - "retry_after": int( - poll_resp.headers.get("retry-after") or 0 - ) - or None, - } - ) - except Exception: - pass - - outputs = latest.get("outputs") or [] - if outputs: - video_url = ((outputs[0] or {}).get("video") or {}).get("presignedUrl") - if not video_url: - raise AdobeRequestError("video job finished without video url") - if out_path is not None: - self._download_to_file( - video_url, - headers={"accept": "*/*"}, - out_path=out_path, - timeout=60, - ) - video_bytes = None - else: - video_resp = self._get(video_url, headers={"accept": "*/*"}, timeout=60) - video_resp.raise_for_status() - video_bytes = video_resp.content - if progress_cb: + if progress_cb and self._is_in_progress_status(status_val): try: progress_cb( { - "task_status": "COMPLETED", - "task_progress": 100.0, + "task_status": "IN_PROGRESS", + "task_progress": progress_val + if progress_val is not None + else 0.0, "upstream_job_id": upstream_job_id, - "retry_after": None, + "retry_after": int( + poll_resp.headers.get("retry-after") or 0 + ) + or None, } ) except Exception: pass - return video_bytes, latest - if status_val in {"FAILED", "CANCELLED", "ERROR"}: + outputs = latest.get("outputs") or [] + if outputs: + video_url = ((outputs[0] or {}).get("video") or {}).get("presignedUrl") + if not video_url: + raise AdobeRequestError("video job finished without video url") + if return_upstream_url: + video_bytes = None + elif out_path is not None: + self._download_to_file( + video_url, + headers={"accept": "*/*"}, + out_path=out_path, + timeout=60, + ) + video_bytes = None + else: + video_resp = self._get( + video_url, + headers={"accept": "*/*"}, + timeout=60, + proxy_kind="resource", + ) + video_resp.raise_for_status() + video_bytes = video_resp.content + if progress_cb: + try: + progress_cb( + { + "task_status": "COMPLETED", + "task_progress": 100.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + } + ) + except Exception: + pass + return video_bytes, latest + + if status_val in {"FAILED", "CANCELLED", "ERROR"}: + if progress_cb: + try: + progress_cb( + { + "task_status": "FAILED", + "task_progress": progress_val + if progress_val is not None + else 0.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + "error": f"video job failed: {latest}", + } + ) + except Exception: + pass + raise AdobeRequestError(f"video job failed: {latest}") + except UpstreamTemporaryError as exc: + can_retry_same_job = self.should_retry_temporary_error(exc) and ( + time.time() - start < timeout + ) + if not can_retry_same_job: + raise + poll_retry_attempt += 1 + retry_delay = max(1.0, self._retry_delay_for_attempt(poll_retry_attempt)) + logger.warning( + "video poll temporary error; retrying same upstream job id=%s attempt=%s delay=%.2fs error=%s", + upstream_job_id, + poll_retry_attempt, + retry_delay, + str(exc), + ) if progress_cb: try: progress_cb( { - "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_status": "IN_PROGRESS", + "task_progress": last_progress, "upstream_job_id": upstream_job_id, - "retry_after": None, - "error": f"video job failed: {latest}", + "retry_after": int(retry_delay), + "error": f"poll retry {poll_retry_attempt}: {str(exc)[:160]}", } ) except Exception: pass - raise AdobeRequestError(f"video job failed: {latest}") + time.sleep(retry_delay) + continue if time.time() - start > timeout: if progress_cb: @@ -909,10 +996,7 @@ def generate_video( progress_cb( { "task_status": "FAILED", - "task_progress": progress_val - if "progress_val" in locals() - and progress_val is not None - else 0.0, + "task_progress": last_progress, "upstream_job_id": upstream_job_id, "retry_after": None, "error": "video generation timed out", @@ -935,6 +1019,7 @@ def generate( timeout: int = 180, out_path: Optional[Path] = None, progress_cb: Optional[Callable[[dict], None]] = None, + return_upstream_url: bool = False, ) -> tuple[Optional[bytes], dict]: submit_resp = None last_error = "" @@ -1017,97 +1102,140 @@ def generate( start = time.time() latest = {} sleep_time = 3.0 + last_progress = 0.0 + poll_retry_attempt = 0 while True: - poll_resp = self._get( - poll_url, headers=self._poll_headers(token), timeout=60 - ) - if poll_resp.status_code != 200: - logger.error( - "poll failed status=%s body=%s", - poll_resp.status_code, - poll_resp.text[:500], + try: + poll_resp = self._get( + poll_url, headers=self._poll_headers(token), timeout=60 ) - if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: - raise UpstreamTemporaryError( - f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", - status_code=poll_resp.status_code, - error_type="status", + if poll_resp.status_code != 200: + logger.error( + "poll failed status=%s body=%s", + poll_resp.status_code, + poll_resp.text[:500], + ) + if poll_resp.status_code in (429, 451) or poll_resp.status_code >= 500: + raise UpstreamTemporaryError( + f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}", + status_code=poll_resp.status_code, + error_type="status", + ) + raise AdobeRequestError( + f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" ) - raise AdobeRequestError( - f"poll failed: {poll_resp.status_code} {poll_resp.text[:300]}" - ) - latest = poll_resp.json() - status_header = str(poll_resp.headers.get("x-task-status") or "").upper() - status_val = str(latest.get("status") or "").upper() or status_header - progress_val = self._extract_progress_percent(latest, poll_resp) + latest = poll_resp.json() + status_header = str(poll_resp.headers.get("x-task-status") or "").upper() + status_val = str(latest.get("status") or "").upper() or status_header + progress_val = self._extract_progress_percent(latest, poll_resp) + if progress_val is not None: + last_progress = progress_val + poll_retry_attempt = 0 - if progress_cb and self._is_in_progress_status(status_val): - try: - progress_cb( - { - "task_status": "IN_PROGRESS", - "task_progress": progress_val - if progress_val is not None - else 0.0, - "upstream_job_id": upstream_job_id, - "retry_after": int( - poll_resp.headers.get("retry-after") or 0 - ) - or None, - } - ) - except Exception: - pass - - outputs = latest.get("outputs") or [] - if outputs: - image_url = ((outputs[0] or {}).get("image") or {}).get("presignedUrl") - if not image_url: - raise AdobeRequestError("job finished without image url") - if out_path is not None: - self._download_to_file( - image_url, - headers={"accept": "*/*"}, - out_path=out_path, - timeout=30, - ) - image_bytes = None - else: - img_resp = self._get(image_url, headers={"accept": "*/*"}, timeout=30) - img_resp.raise_for_status() - image_bytes = img_resp.content - if progress_cb: + if progress_cb and self._is_in_progress_status(status_val): try: progress_cb( { - "task_status": "COMPLETED", - "task_progress": 100.0, + "task_status": "IN_PROGRESS", + "task_progress": progress_val + if progress_val is not None + else 0.0, "upstream_job_id": upstream_job_id, - "retry_after": None, + "retry_after": int( + poll_resp.headers.get("retry-after") or 0 + ) + or None, } ) except Exception: pass - return image_bytes, latest - if status_val in {"FAILED", "CANCELLED", "ERROR"}: + outputs = latest.get("outputs") or [] + if outputs: + image_url = ((outputs[0] or {}).get("image") or {}).get("presignedUrl") + if not image_url: + raise AdobeRequestError("job finished without image url") + if return_upstream_url: + image_bytes = None + elif out_path is not None: + self._download_to_file( + image_url, + headers={"accept": "*/*"}, + out_path=out_path, + timeout=30, + ) + image_bytes = None + else: + img_resp = self._get( + image_url, + headers={"accept": "*/*"}, + timeout=30, + proxy_kind="resource", + ) + img_resp.raise_for_status() + image_bytes = img_resp.content + if progress_cb: + try: + progress_cb( + { + "task_status": "COMPLETED", + "task_progress": 100.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + } + ) + except Exception: + pass + return image_bytes, latest + + if status_val in {"FAILED", "CANCELLED", "ERROR"}: + if progress_cb: + try: + progress_cb( + { + "task_status": "FAILED", + "task_progress": progress_val + if progress_val is not None + else 0.0, + "upstream_job_id": upstream_job_id, + "retry_after": None, + "error": f"image job failed: {latest}", + } + ) + except Exception: + pass + raise AdobeRequestError(f"image job failed: {latest}") + except UpstreamTemporaryError as exc: + can_retry_same_job = self.should_retry_temporary_error(exc) and ( + time.time() - start < timeout + ) + if not can_retry_same_job: + raise + poll_retry_attempt += 1 + retry_delay = max(1.0, self._retry_delay_for_attempt(poll_retry_attempt)) + logger.warning( + "image poll temporary error; retrying same upstream job id=%s attempt=%s delay=%.2fs error=%s", + upstream_job_id, + poll_retry_attempt, + retry_delay, + str(exc), + ) if progress_cb: try: progress_cb( { - "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_status": "IN_PROGRESS", + "task_progress": last_progress, "upstream_job_id": upstream_job_id, - "retry_after": None, - "error": f"image job failed: {latest}", + "retry_after": int(retry_delay), + "error": f"poll retry {poll_retry_attempt}: {str(exc)[:160]}", } ) except Exception: pass - raise AdobeRequestError(f"image job failed: {latest}") + time.sleep(retry_delay) + continue if time.time() - start > timeout: if progress_cb: @@ -1115,9 +1243,7 @@ def generate( progress_cb( { "task_status": "FAILED", - "task_progress": progress_val - if progress_val is not None - else 0.0, + "task_progress": last_progress, "upstream_job_id": upstream_job_id, "retry_after": None, "error": "image generation timed out", diff --git a/core/config_mgr.py b/core/config_mgr.py index 2367949..81325b4 100644 --- a/core/config_mgr.py +++ b/core/config_mgr.py @@ -22,6 +22,8 @@ def __init__(self): "public_base_url": "http://127.0.0.1:6001/", "proxy": "", "use_proxy": False, + "resource_proxy": "", + "resource_use_proxy": False, "generate_timeout": 300, "refresh_interval_hours": 15, "retry_enabled": True, @@ -33,6 +35,10 @@ def __init__(self): "batch_concurrency": 5, "generated_max_size_mb": 1024, "generated_prune_size_mb": 200, + "use_upstream_result_url": False, + "imgbed_enabled": False, + "imgbed_api_url": "", + "imgbed_api_key": "", } self.load() @@ -45,6 +51,16 @@ def load(self): for k, v in data.items(): if k in self.config: self.config[k] = v + if ( + isinstance(data, dict) + and "resource_proxy" not in data + and "resource_use_proxy" not in data + ): + legacy_proxy = str(data.get("proxy", "") or "").strip() + legacy_use_proxy = bool(data.get("use_proxy", False)) + if legacy_proxy and legacy_use_proxy: + self.config["resource_proxy"] = legacy_proxy + self.config["resource_use_proxy"] = True if source == LEGACY_CONFIG_FILE and not CONFIG_FILE.exists(): CONFIG_FILE.write_text( json.dumps(self.config, indent=2), encoding="utf-8" diff --git a/core/imgbed_client.py b/core/imgbed_client.py new file mode 100644 index 0000000..0922980 --- /dev/null +++ b/core/imgbed_client.py @@ -0,0 +1,183 @@ +import mimetypes +import os +import tempfile +import uuid +from pathlib import Path +from urllib.parse import parse_qsl, urlencode, urljoin, urlparse, urlunparse + +import requests + +from core.proxy_utils import build_requests_proxies, resolve_resource_proxy + + +class ImgBedUploadError(Exception): + pass + + +class ImgBedClient: + def __init__(self) -> None: + self.enabled = False + self.api_url = "" + self.api_key = "" + self.resource_proxy = "" + self.timeout = 300 + + def apply_config(self, cfg: dict) -> None: + self.enabled = bool(cfg.get("imgbed_enabled", False)) + self.api_url = str(cfg.get("imgbed_api_url", "") or "").strip() + self.api_key = str(cfg.get("imgbed_api_key", "") or "").strip() + self.resource_proxy = resolve_resource_proxy(cfg) + timeout_val = cfg.get("generate_timeout", 300) + try: + timeout_val = int(timeout_val) + except Exception: + timeout_val = 300 + self.timeout = timeout_val if timeout_val > 0 else 300 + + def is_enabled(self) -> bool: + return self.enabled + + def is_ready(self) -> bool: + return self.enabled and bool(self.api_url) and bool(self.api_key) + + def _requests_proxies(self) -> dict | None: + return build_requests_proxies(self.resource_proxy) + + def _build_upload_url(self) -> str: + raw = str(self.api_url or "").strip() + if not raw: + raise ImgBedUploadError("imgbed_api_url is empty") + parsed = urlparse(raw) + if parsed.scheme not in {"http", "https"}: + raise ImgBedUploadError("imgbed_api_url must start with http:// or https://") + if not self.api_key: + raise ImgBedUploadError("imgbed_api_key is empty") + query = dict(parse_qsl(parsed.query, keep_blank_values=True)) + query["authCode"] = self.api_key + query["returnFormat"] = "full" + return urlunparse(parsed._replace(query=urlencode(query))) + + def _parse_response_url(self, payload) -> str: + src = "" + if isinstance(payload, list) and payload: + first = payload[0] if isinstance(payload[0], dict) else {} + src = str(first.get("src") or first.get("url") or "").strip() + elif isinstance(payload, dict): + data = payload.get("data") + if isinstance(data, list) and data: + first = data[0] if isinstance(data[0], dict) else {} + src = str(first.get("src") or first.get("url") or "").strip() + elif isinstance(data, dict): + src = str(data.get("src") or data.get("url") or "").strip() + if not src: + src = str(payload.get("src") or payload.get("url") or "").strip() + if not src: + raise ImgBedUploadError("imgbed upload succeeded but no file url returned") + if src.startswith(("http://", "https://")): + return src + parsed = urlparse(self.api_url) + base = f"{parsed.scheme}://{parsed.netloc}/" + return urljoin(base, src.lstrip("/")) + + def upload_bytes( + self, filename: str, content: bytes, mime_type: str | None = None + ) -> str: + if not content: + raise ImgBedUploadError("imgbed upload content is empty") + safe_name = str(filename or "").strip() or f"{uuid.uuid4().hex}.bin" + guessed_type = mime_type or mimetypes.guess_type(safe_name)[0] + upload_url = self._build_upload_url() + try: + resp = requests.post( + upload_url, + files={ + "file": ( + safe_name, + content, + guessed_type or "application/octet-stream", + ) + }, + timeout=self.timeout, + proxies=self._requests_proxies(), + ) + resp.raise_for_status() + except requests.RequestException as exc: + raise ImgBedUploadError(f"imgbed upload failed: {exc}") from exc + try: + payload = resp.json() + except Exception as exc: + raise ImgBedUploadError("imgbed upload returned invalid JSON") from exc + return self._parse_response_url(payload) + + def upload_file( + self, file_path: Path, filename: str | None = None, mime_type: str | None = None + ) -> str: + path = Path(file_path) + if not path.exists() or not path.is_file(): + raise ImgBedUploadError("imgbed upload file not found") + safe_name = str(filename or path.name).strip() or path.name + guessed_type = mime_type or mimetypes.guess_type(safe_name)[0] + upload_url = self._build_upload_url() + try: + with path.open("rb") as f: + resp = requests.post( + upload_url, + files={ + "file": ( + safe_name, + f, + guessed_type or "application/octet-stream", + ) + }, + timeout=self.timeout, + proxies=self._requests_proxies(), + ) + resp.raise_for_status() + except requests.RequestException as exc: + raise ImgBedUploadError(f"imgbed upload failed: {exc}") from exc + try: + payload = resp.json() + except Exception as exc: + raise ImgBedUploadError("imgbed upload returned invalid JSON") from exc + return self._parse_response_url(payload) + + def upload_from_url( + self, source_url: str, filename: str, mime_type: str | None = None + ) -> str: + raw_url = str(source_url or "").strip() + if not raw_url.startswith(("http://", "https://")): + raise ImgBedUploadError("imgbed source url must start with http:// or https://") + suffix = Path(str(filename or "")).suffix or Path(urlparse(raw_url).path).suffix + temp_path = None + try: + with requests.get( + raw_url, + timeout=self.timeout, + proxies=self._requests_proxies(), + stream=True, + ) as resp: + resp.raise_for_status() + guessed_type = mime_type or ( + (resp.headers.get("content-type") or "").split(";", 1)[0].strip() + or None + ) + with tempfile.NamedTemporaryFile( + delete=False, suffix=suffix or ".bin" + ) as tmp: + temp_path = Path(tmp.name) + for chunk in resp.iter_content(chunk_size=1024 * 1024): + if chunk: + tmp.write(chunk) + return self.upload_file( + temp_path, + filename=filename, + mime_type=guessed_type, + ) + except requests.RequestException as exc: + raise ImgBedUploadError(f"imgbed source download failed: {exc}") from exc + finally: + if temp_path is not None: + try: + os.remove(temp_path) + except Exception: + pass diff --git a/core/models/catalog.py b/core/models/catalog.py index e5ec04a..3d8edc1 100644 --- a/core/models/catalog.py +++ b/core/models/catalog.py @@ -12,126 +12,330 @@ MODEL_CATALOG: dict[str, dict] = {} -def _register_nano_banana_family( - prefix: str, +def _register_image_model( + model_id: str, *, upstream_model_id: str, upstream_model_version: str, family_label: str, + fixed_output_resolution: str | None = None, ) -> None: + resolution_options = ( + [fixed_output_resolution] + if fixed_output_resolution + else ["1K", "2K", "4K"] + ) + MODEL_CATALOG[model_id] = { + "upstream_model": "google:firefly:colligo:nano-banana-pro", + "upstream_model_id": upstream_model_id, + "upstream_model_version": upstream_model_version, + "output_resolution": fixed_output_resolution or "2K", + "output_resolution_options": resolution_options, + "aspect_ratio": "16:9", + "aspect_ratio_options": ["1:1", "16:9", "9:16", "4:3", "3:4"], + "description": ( + f"{family_label} 4K image model (set aspect_ratio in request)" + if fixed_output_resolution == "4K" + else f"{family_label} image model (set output_resolution/aspect_ratio in request)" + ), + "allow_request_overrides": True, + } + for res in ("1k", "2k", "4k"): for ratio, suffix in RATIO_SUFFIX_MAP.items(): - model_id = f"{prefix}-{res}-{suffix}" - MODEL_CATALOG[model_id] = { - "upstream_model": "google:firefly:colligo:nano-banana-pro", - "upstream_model_id": upstream_model_id, - "upstream_model_version": upstream_model_version, - "output_resolution": res.upper(), - "aspect_ratio": ratio, - "description": f"{family_label} ({res.upper()} {ratio})", - } - - -_register_nano_banana_family( - "firefly-nano-banana-pro", + for alias_id in (f"{model_id}-{res}-{suffix}", f"firefly-{model_id}-{res}-{suffix}"): + MODEL_CATALOG[alias_id] = { + "upstream_model": "google:firefly:colligo:nano-banana-pro", + "upstream_model_id": upstream_model_id, + "upstream_model_version": upstream_model_version, + "output_resolution": res.upper(), + "aspect_ratio": ratio, + "description": f"{family_label} ({res.upper()} {ratio})", + "canonical_model": model_id, + "hidden": True, + "allow_request_overrides": False, + } + + +def _register_image_family_alias(alias_id: str, canonical_model: str) -> None: + base = dict(MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "hidden": True, + "allow_request_overrides": True, + } + ) + MODEL_CATALOG[alias_id] = base + + +def _register_image_fixed_resolution_alias( + alias_id: str, canonical_model: str, output_resolution: str +) -> None: + base = dict(MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "output_resolution": output_resolution, + "output_resolution_options": [output_resolution], + "hidden": True, + "allow_request_overrides": True, + } + ) + MODEL_CATALOG[alias_id] = base + + +_register_image_model( + "nano-banana-pro", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", - family_label="Firefly Nano Banana Pro", + family_label="Nano Banana Pro", ) -_register_nano_banana_family( - "firefly-nano-banana", +_register_image_model( + "nano-banana", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-2", - family_label="Firefly Nano Banana", + family_label="Nano Banana", ) -_register_nano_banana_family( - "firefly-nano-banana2", +_register_image_model( + "nano-banana2", upstream_model_id="gemini-flash", upstream_model_version="nano-banana-3", - family_label="Firefly Nano Banana 2", + family_label="Nano Banana 2", ) -DEFAULT_MODEL_ID = "firefly-nano-banana-pro-2k-16x9" +for canonical_id in ( + "nano-banana", + "nano-banana-pro", + "nano-banana2", +): + _register_image_family_alias(f"firefly-{canonical_id}", canonical_id) + +_register_image_fixed_resolution_alias("nano-banana-4k", "nano-banana", "4K") +_register_image_fixed_resolution_alias("firefly-nano-banana-4k", "nano-banana", "4K") +_register_image_fixed_resolution_alias("nano-banana-pro-4k", "nano-banana-pro", "4K") +_register_image_fixed_resolution_alias( + "firefly-nano-banana-pro-4k", "nano-banana-pro", "4K" +) +_register_image_fixed_resolution_alias("nano-banana2-4k", "nano-banana2", "4K") +_register_image_fixed_resolution_alias("firefly-nano-banana2-4k", "nano-banana2", "4K") + +DEFAULT_MODEL_ID = "nano-banana-pro" + +VIDEO_MODEL_CATALOG: dict[str, dict] = {} + + +def _register_video_model( + model_id: str, + *, + description: str, + engine: str = "sora2", + upstream_model: str | None = None, + duration: int = 8, + duration_options: tuple[int, ...] = (), + aspect_ratio: str = "16:9", + aspect_ratio_options: tuple[str, ...] = (), + resolution: str | None = None, + resolution_options: tuple[str, ...] = (), + reference_mode: str = "frame", + reference_mode_options: tuple[str, ...] = (), +) -> None: + VIDEO_MODEL_CATALOG[model_id] = { + "description": description, + "engine": engine, + "upstream_model": upstream_model, + "duration": duration, + "duration_options": list(duration_options or (duration,)), + "aspect_ratio": aspect_ratio, + "aspect_ratio_options": list(aspect_ratio_options or (aspect_ratio,)), + "resolution": resolution, + "resolution_options": list(resolution_options), + "reference_mode": reference_mode, + "reference_mode_options": list(reference_mode_options or (reference_mode,)), + "allow_request_overrides": True, + } -VIDEO_MODEL_CATALOG: dict[str, dict] = { - "firefly-sora2-4s-9x16": { - "duration": 4, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (4s 9:16)", - }, - "firefly-sora2-4s-16x9": { - "duration": 4, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (4s 16:9)", - }, - "firefly-sora2-8s-9x16": { - "duration": 8, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (8s 9:16)", - }, - "firefly-sora2-8s-16x9": { - "duration": 8, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (8s 16:9)", - }, - "firefly-sora2-12s-9x16": { - "duration": 12, - "aspect_ratio": "9:16", - "description": "Firefly Sora2 video model (12s 9:16)", - }, - "firefly-sora2-12s-16x9": { - "duration": 12, - "aspect_ratio": "16:9", - "description": "Firefly Sora2 video model (12s 16:9)", - }, -} + +def _register_video_family_alias(alias_id: str, canonical_model: str) -> None: + base = dict(VIDEO_MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "hidden": True, + "allow_request_overrides": True, + } + ) + VIDEO_MODEL_CATALOG[alias_id] = base + + +def _register_video_alias( + alias_id: str, + *, + canonical_model: str, + duration: int, + aspect_ratio: str, + resolution: str | None = None, + reference_mode: str = "frame", + description: str, +) -> None: + base = dict(VIDEO_MODEL_CATALOG[canonical_model]) + base.update( + { + "canonical_model": canonical_model, + "description": description, + "duration": duration, + "aspect_ratio": aspect_ratio, + "resolution": resolution, + "reference_mode": reference_mode, + "hidden": True, + "allow_request_overrides": False, + } + ) + VIDEO_MODEL_CATALOG[alias_id] = base + + +_register_video_model( + "sora2", + description="Sora2 video model (set duration/aspect_ratio in request)", + engine="sora2", + upstream_model="openai:firefly:colligo:sora2", + duration=8, + duration_options=(4, 8, 12), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), +) + +_register_video_model( + "sora2-pro", + description="Sora2 Pro video model (set duration/aspect_ratio in request)", + engine="sora2", + upstream_model="openai:firefly:colligo:sora2-pro", + duration=8, + duration_options=(4, 8, 12), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), +) + +_register_video_model( + "veo31", + description="Veo31 video model (set duration/aspect_ratio/resolution/reference_mode in request)", + engine="veo31-standard", + upstream_model="google:firefly:colligo:veo31", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="frame", + reference_mode_options=("frame", "image"), +) + +_register_video_model( + "veo31-ref", + description="Veo31 Ref video model (set duration/aspect_ratio/resolution in request)", + engine="veo31-standard", + upstream_model="google:firefly:colligo:veo31", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="image", + reference_mode_options=("image",), +) + +_register_video_model( + "veo31-fast", + description="Veo31 Fast video model (set duration/aspect_ratio/resolution in request)", + engine="veo31-fast", + upstream_model="google:firefly:colligo:veo31-fast", + duration=4, + duration_options=(4, 6, 8), + aspect_ratio="16:9", + aspect_ratio_options=("16:9", "9:16"), + resolution="720p", + resolution_options=("720p", "1080p"), + reference_mode="frame", +) + +for canonical_id in ("sora2", "sora2-pro", "veo31", "veo31-ref", "veo31-fast"): + _register_video_family_alias(f"firefly-{canonical_id}", canonical_id) for dur in (4, 8, 12): for ratio in ("9:16", "16:9"): - model_id = f"firefly-sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}" - VIDEO_MODEL_CATALOG[model_id] = { - "duration": dur, - "aspect_ratio": ratio, - "upstream_model": "openai:firefly:colligo:sora2-pro", - "description": f"Firefly Sora2 Pro video model ({dur}s {ratio})", - } + for alias_id in ( + f"sora2-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + f"firefly-sora2-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + ): + _register_video_alias( + alias_id, + canonical_model="sora2", + duration=dur, + aspect_ratio=ratio, + description=f"Sora2 video model ({dur}s {ratio})", + ) + +for dur in (4, 8, 12): + for ratio in ("9:16", "16:9"): + for alias_id in ( + f"sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + f"firefly-sora2-pro-{dur}s-{RATIO_SUFFIX_MAP[ratio]}", + ): + _register_video_alias( + alias_id, + canonical_model="sora2-pro", + duration=dur, + aspect_ratio=ratio, + description=f"Sora2 Pro video model ({dur}s {ratio})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-standard", - "upstream_model": "google:firefly:colligo:veo31", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "description": f"Firefly Veo31 video model ({dur}s {ratio} {res})", - } + for alias_id in ( + f"veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + f"firefly-veo31-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + ): + _register_video_alias( + alias_id, + canonical_model="veo31", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Veo31 video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-standard", - "upstream_model": "google:firefly:colligo:veo31", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "reference_mode": "image", - "description": f"Firefly Veo31 Ref video model ({dur}s {ratio} {res})", - } + for alias_id in ( + f"veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + f"firefly-veo31-ref-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + ): + _register_video_alias( + alias_id, + canonical_model="veo31-ref", + duration=dur, + aspect_ratio=ratio, + resolution=res, + reference_mode="image", + description=f"Veo31 Ref video model ({dur}s {ratio} {res})", + ) for dur in (4, 6, 8): for ratio in ("16:9", "9:16"): for res in ("1080p", "720p"): - model_id = f"firefly-veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}" - VIDEO_MODEL_CATALOG[model_id] = { - "engine": "veo31-fast", - "upstream_model": "google:firefly:colligo:veo31-fast", - "duration": dur, - "aspect_ratio": ratio, - "resolution": res, - "description": f"Firefly Veo31 Fast video model ({dur}s {ratio} {res})", - } + for alias_id in ( + f"veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + f"firefly-veo31-fast-{dur}s-{RATIO_SUFFIX_MAP[ratio]}-{res}", + ): + _register_video_alias( + alias_id, + canonical_model="veo31-fast", + duration=dur, + aspect_ratio=ratio, + resolution=res, + description=f"Veo31 Fast video model ({dur}s {ratio} {res})", + ) diff --git a/core/models/resolver.py b/core/models/resolver.py index 5d0f6d7..542a618 100644 --- a/core/models/resolver.py +++ b/core/models/resolver.py @@ -30,21 +30,44 @@ def ratio_from_size(size: str) -> str: return mapping.get(str(size or "").strip(), "1:1") +def _normalize_output_resolution(value: str) -> str: + normalized = str(value or "").strip().upper() + aliases = { + "1K": "1K", + "HD": "2K", + "2K": "2K", + "4K": "4K", + "ULTRA": "4K", + } + return aliases.get(normalized, normalized or "2K") + + def resolve_ratio_and_resolution( data: dict, model_id: Optional[str] ) -> tuple[str, str, str]: - ratio = str(data.get("aspect_ratio") or "").strip() or ratio_from_size( - data.get("size", "1024x1024") - ) - if ratio not in SUPPORTED_RATIOS: - ratio = "1:1" - resolved_model_id = model_id or DEFAULT_MODEL_ID if resolved_model_id not in MODEL_CATALOG: resolved_model_id = DEFAULT_MODEL_ID model_conf = MODEL_CATALOG[resolved_model_id] - output_resolution = model_conf["output_resolution"] + if not model_conf.get("allow_request_overrides"): + ratio = str(model_conf.get("aspect_ratio") or "1:1").strip() + output_resolution = str(model_conf.get("output_resolution") or "2K").upper() + return ( + ratio, + output_resolution, + str(model_conf.get("canonical_model") or resolved_model_id), + ) + + ratio = str(data.get("aspect_ratio") or "").strip() or ratio_from_size( + data.get("size", "1024x1024") + ) + if ratio not in SUPPORTED_RATIOS: + ratio = str(model_conf.get("aspect_ratio") or "1:1").strip() + + output_resolution = _normalize_output_resolution( + data.get("output_resolution") or model_conf.get("output_resolution") or "2K" + ) if not model_id: quality = str(data.get("quality", "2k")).lower() if quality in ("4k", "ultra"): @@ -54,8 +77,12 @@ def resolve_ratio_and_resolution( else: output_resolution = "1K" - model_ratio = model_conf.get("aspect_ratio") - if model_ratio: - ratio = model_ratio + allowed_resolutions = [ + str(item).strip().upper() + for item in (model_conf.get("output_resolution_options") or []) + if str(item).strip() + ] + if allowed_resolutions and output_resolution not in allowed_resolutions: + output_resolution = str(model_conf.get("output_resolution") or "2K").upper() - return ratio, output_resolution, resolved_model_id + return ratio, output_resolution, str(model_conf.get("canonical_model") or resolved_model_id) diff --git a/core/proxy_utils.py b/core/proxy_utils.py new file mode 100644 index 0000000..05eb0c7 --- /dev/null +++ b/core/proxy_utils.py @@ -0,0 +1,133 @@ +import time +from typing import Any + +import requests + + +def resolve_basic_proxy(cfg: dict) -> str: + proxy = str(cfg.get("proxy", "") or "").strip() + use_proxy = bool(cfg.get("use_proxy", False)) + return proxy if use_proxy and proxy else "" + + +def resolve_resource_proxy(cfg: dict) -> str: + proxy = str(cfg.get("resource_proxy", "") or "").strip() + use_proxy = bool(cfg.get("resource_use_proxy", False)) + return proxy if use_proxy and proxy else "" + + +def build_requests_proxies(proxy: str) -> dict[str, str] | None: + raw = str(proxy or "").strip() + if not raw: + return None + return {"http": raw, "https": raw} + + +def test_proxy_endpoint( + *, + proxy_label: str, + proxy: str, + target_url: str, + timeout: float = 10.0, +) -> dict[str, Any]: + started = time.time() + if not proxy: + return { + "name": proxy_label, + "enabled": False, + "ok": False, + "target_url": target_url, + "proxy": "", + "elapsed_ms": 0, + "status_code": None, + "message": "proxy disabled", + } + + try: + resp = requests.get( + target_url, + timeout=max(1.0, float(timeout)), + proxies=build_requests_proxies(proxy), + allow_redirects=False, + ) + elapsed_ms = round((time.time() - started) * 1000, 2) + return { + "name": proxy_label, + "enabled": True, + "ok": True, + "target_url": target_url, + "proxy": proxy, + "elapsed_ms": elapsed_ms, + "status_code": int(resp.status_code), + "message": f"http response received ({resp.status_code})", + } + except Exception as exc: + elapsed_ms = round((time.time() - started) * 1000, 2) + return { + "name": proxy_label, + "enabled": True, + "ok": False, + "target_url": target_url, + "proxy": proxy, + "elapsed_ms": elapsed_ms, + "status_code": None, + "message": str(exc), + } + + +def test_authorized_endpoint( + *, + check_name: str, + proxy: str, + target_url: str, + headers: dict[str, str], + timeout: float = 15.0, +) -> dict[str, Any]: + started = time.time() + if not proxy: + return { + "name": check_name, + "enabled": False, + "ok": False, + "target_url": target_url, + "proxy": "", + "elapsed_ms": 0, + "status_code": None, + "message": "proxy disabled", + } + + try: + resp = requests.get( + target_url, + headers=headers, + timeout=max(1.0, float(timeout)), + proxies=build_requests_proxies(proxy), + ) + elapsed_ms = round((time.time() - started) * 1000, 2) + status_code = int(resp.status_code) + return { + "name": check_name, + "enabled": True, + "ok": status_code == 200, + "target_url": target_url, + "proxy": proxy, + "elapsed_ms": elapsed_ms, + "status_code": status_code, + "message": ( + "authorized request succeeded" + if status_code == 200 + else f"authorized request failed ({status_code})" + ), + } + except Exception as exc: + elapsed_ms = round((time.time() - started) * 1000, 2) + return { + "name": check_name, + "enabled": True, + "ok": False, + "target_url": target_url, + "proxy": proxy, + "elapsed_ms": elapsed_ms, + "status_code": None, + "message": str(exc), + } diff --git a/core/redis_health.py b/core/redis_health.py new file mode 100644 index 0000000..4117432 --- /dev/null +++ b/core/redis_health.py @@ -0,0 +1,180 @@ +import os +import socket +import ssl +import time +from dataclasses import dataclass +from typing import Optional +from urllib.parse import unquote, urlparse + + +@dataclass +class RedisConfig: + host: str + port: int + password: str = "" + username: str = "" + db: int = 0 + timeout: float = 5.0 + use_ssl: bool = False + + +def _env_first(*keys: str) -> str: + for key in keys: + value = str(os.getenv(key, "") or "").strip() + if value: + return value + return "" + + +def load_redis_config_from_env() -> Optional[RedisConfig]: + url = _env_first("REDIS_URL", "REDIS_URI") + if url: + parsed = urlparse(url) + if parsed.scheme not in {"redis", "rediss"} or not parsed.hostname: + return None + db = 0 + try: + raw_path = str(parsed.path or "").strip("/") + if raw_path: + db = max(0, int(raw_path)) + except Exception: + db = 0 + return RedisConfig( + host=str(parsed.hostname), + port=int(parsed.port or 6379), + username=unquote(parsed.username or ""), + password=unquote(parsed.password or ""), + db=db, + timeout=_load_timeout(), + use_ssl=(parsed.scheme == "rediss"), + ) + + host = _env_first("REDIS_HOST") + if not host: + return None + try: + port = int(_env_first("REDIS_PORT") or "6379") + except Exception: + port = 6379 + try: + db = int(_env_first("REDIS_DB") or "0") + except Exception: + db = 0 + ssl_raw = _env_first("REDIS_SSL", "REDIS_USE_SSL").lower() + use_ssl = ssl_raw in {"1", "true", "yes", "on"} + return RedisConfig( + host=host, + port=port, + username=_env_first("REDIS_USERNAME"), + password=_env_first("REDIS_PASSWORD"), + db=max(0, db), + timeout=_load_timeout(), + use_ssl=use_ssl, + ) + + +def _load_timeout() -> float: + raw = _env_first("REDIS_TIMEOUT", "REDIS_CONNECT_TIMEOUT") + if not raw: + return 5.0 + try: + timeout = float(raw) + except Exception: + timeout = 5.0 + return min(max(timeout, 1.0), 30.0) + + +def _resp_command(*parts: str) -> bytes: + encoded = [str(part).encode("utf-8") for part in parts] + payload = [f"*{len(encoded)}\r\n".encode("ascii")] + for item in encoded: + payload.append(f"${len(item)}\r\n".encode("ascii")) + payload.append(item + b"\r\n") + return b"".join(payload) + + +def _read_resp_line(reader) -> str: + raw = reader.readline() + if not raw: + raise RuntimeError("redis connection closed unexpectedly") + if raw.startswith(b"+"): + return raw[1:].decode("utf-8", errors="replace").strip() + if raw.startswith(b"-"): + raise RuntimeError(raw[1:].decode("utf-8", errors="replace").strip()) + if raw.startswith(b":"): + return raw[1:].decode("utf-8", errors="replace").strip() + raise RuntimeError(f"unexpected redis response: {raw[:80]!r}") + + +def check_redis_connection() -> dict: + cfg = load_redis_config_from_env() + if cfg is None: + return { + "configured": False, + "ok": False, + "host": None, + "port": None, + "db": None, + "ssl": False, + "checked_at": int(time.time()), + "error": "redis environment variables not configured", + } + + sock = None + reader = None + try: + sock = socket.create_connection((cfg.host, cfg.port), timeout=cfg.timeout) + sock.settimeout(cfg.timeout) + if cfg.use_ssl: + context = ssl.create_default_context() + sock = context.wrap_socket(sock, server_hostname=cfg.host) + reader = sock.makefile("rb") + + if cfg.password: + if cfg.username: + sock.sendall(_resp_command("AUTH", cfg.username, cfg.password)) + else: + sock.sendall(_resp_command("AUTH", cfg.password)) + _read_resp_line(reader) + + if int(cfg.db) > 0: + sock.sendall(_resp_command("SELECT", str(cfg.db))) + _read_resp_line(reader) + + sock.sendall(_resp_command("PING")) + pong = _read_resp_line(reader).upper() + if pong != "PONG": + raise RuntimeError(f"unexpected ping response: {pong}") + + return { + "configured": True, + "ok": True, + "host": cfg.host, + "port": cfg.port, + "db": cfg.db, + "ssl": bool(cfg.use_ssl), + "checked_at": int(time.time()), + "error": None, + } + except Exception as exc: + return { + "configured": True, + "ok": False, + "host": cfg.host, + "port": cfg.port, + "db": cfg.db, + "ssl": bool(cfg.use_ssl), + "checked_at": int(time.time()), + "error": str(exc), + } + finally: + if reader is not None: + try: + reader.close() + except Exception: + pass + if sock is not None: + try: + sock.close() + except Exception: + pass diff --git a/core/refresh_mgr.py b/core/refresh_mgr.py index ee7ca43..ad1cf78 100644 --- a/core/refresh_mgr.py +++ b/core/refresh_mgr.py @@ -9,6 +9,7 @@ import requests from core.config_mgr import config_manager +from core.proxy_utils import build_requests_proxies, resolve_basic_proxy from core.token_mgr import token_manager @@ -187,11 +188,7 @@ def _refresh_interval_seconds(cls) -> int: return cls._refresh_interval_hours() * 3600 def _requests_proxies(self): - proxy = str(config_manager.get("proxy", "") or "").strip() - use_proxy = bool(config_manager.get("use_proxy", False)) - if not (use_proxy and proxy): - return None - return {"http": proxy, "https": proxy} + return build_requests_proxies(resolve_basic_proxy(config_manager.get_all())) def _summary_locked(self, profile: Dict) -> Dict: endpoint = profile.get("endpoint", {}) diff --git a/core/stores.py b/core/stores.py index 4563651..74944ae 100644 --- a/core/stores.py +++ b/core/stores.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json import threading import time @@ -13,6 +15,8 @@ class JobRecord: id: str prompt: str aspect_ratio: str + model: Optional[str] = None + kind: Optional[str] = None status: str = "queued" progress: float = 0.0 image_url: Optional[str] = None @@ -33,12 +37,20 @@ def _cleanup(self): for item in sorted_items[:50]: self._items.pop(item.id, None) - def create(self, prompt: str, aspect_ratio: str) -> JobRecord: + def create( + self, + prompt: str, + aspect_ratio: str, + model: Optional[str] = None, + kind: Optional[str] = None, + ) -> JobRecord: now = time.time() item = JobRecord( id=uuid.uuid4().hex, prompt=prompt, aspect_ratio=aspect_ratio, + model=model, + kind=kind, created_at=now, updated_at=now, ) @@ -70,9 +82,11 @@ class RequestLogRecord: status_code: int duration_sec: int operation: str + request_id: Optional[str] = None preview_url: Optional[str] = None preview_kind: Optional[str] = None model: Optional[str] = None + model_params: Optional[str] = None prompt_preview: Optional[str] = None error: Optional[str] = None error_code: Optional[str] = None @@ -118,6 +132,111 @@ def _append_payload_locked(self, payload: dict) -> None: self._truncate_to_max_locked() self._append_since_truncate = 0 + def _read_payloads_locked(self) -> list[dict]: + items: list[dict] = [] + with self._file_path.open("r", encoding="utf-8") as f: + for line in f: + raw = line.strip() + if not raw: + continue + try: + item = json.loads(raw) + except Exception: + continue + if isinstance(item, dict): + items.append(item) + return items + + @staticmethod + def _get_account_values(item: dict) -> list[str]: + values: list[str] = [] + if not isinstance(item, dict): + return values + for key in ("token_account_email", "token_account_name", "token_id"): + text = str(item.get(key) or "").strip() + if text: + values.append(text) + return values + + @classmethod + def _match_account_filter(cls, item: dict, account: str) -> bool: + target = str(account or "").strip().casefold() + if not target: + return True + for value in cls._get_account_values(item): + if value.casefold() == target: + return True + return False + + @staticmethod + def _resolve_media_kind(item: dict) -> str: + if not isinstance(item, dict): + return "" + + preview_kind = str(item.get("preview_kind") or "").strip().lower() + if preview_kind in {"image", "video"}: + return preview_kind + + model = str(item.get("model") or "").strip().lower() + if model: + if ( + "sora" in model + or "veo" in model + or "video" in model + or "text2video" in model + ): + return "video" + return "image" + + path = str(item.get("path") or "").strip().lower() + operation = str(item.get("operation") or "").strip().lower() + if path.endswith("/v1/video/generations") or operation == "video.generations": + return "video" + if path.endswith("/v1/images/generations") or operation == "images.generations": + return "image" + if path.endswith("/v1/chat/completions") or operation == "chat.completions": + return "image" + return "" + + @classmethod + def _apply_filters( + cls, + items: list[dict], + *, + start_ts: Optional[float] = None, + end_ts: Optional[float] = None, + failed_only: bool = False, + account: str = "", + media_kind: str = "", + ) -> list[dict]: + filtered: list[dict] = [] + normalized_media_kind = str(media_kind or "").strip().lower() + for item in items: + if not isinstance(item, dict): + continue + try: + ts_val = float(item.get("ts") or 0) + except Exception: + ts_val = 0.0 + if start_ts is not None and ts_val < float(start_ts): + continue + if end_ts is not None and ts_val > float(end_ts): + continue + + try: + status_code = int(item.get("status_code") or 0) + except Exception: + status_code = 0 + if failed_only and status_code < 400: + continue + if account and not cls._match_account_filter(item, account): + continue + if normalized_media_kind: + if cls._resolve_media_kind(item) != normalized_media_kind: + continue + filtered.append(item) + return filtered + def add(self, item: RequestLogRecord) -> None: payload = asdict(item) self.add_payload(payload) @@ -138,41 +257,136 @@ def upsert(self, item_id: str, payload: dict) -> None: with self._lock: self._append_payload_locked(item) - def list(self, limit: int = 20, page: int = 1) -> tuple[list[dict], int]: + def list( + self, + limit: int = 20, + page: int = 1, + *, + failed_only: bool = False, + account: str = "", + media_kind: str = "", + start_ts: Optional[float] = None, + end_ts: Optional[float] = None, + ) -> tuple[list[dict], int]: safe_limit = min(max(int(limit or 20), 1), 100) safe_page = max(int(page or 1), 1) - window_size = safe_limit * safe_page - tail: deque[str] = deque(maxlen=window_size) - total = 0 with self._lock: - with self._file_path.open("r", encoding="utf-8") as f: - for line in f: - total += 1 - tail.append(line) + items = self._read_payloads_locked() + + filtered = self._apply_filters( + items, + start_ts=start_ts, + end_ts=end_ts, + failed_only=failed_only, + account=account, + media_kind=media_kind, + ) + total = len(filtered) if total <= 0: return [], 0 - tail_lines = list(tail) - available = len(tail_lines) - start_from_end = (safe_page - 1) * safe_limit - if start_from_end >= available: + end_idx = total - ((safe_page - 1) * safe_limit) + if end_idx <= 0: return [], total - end_idx = available - start_from_end start_idx = max(0, end_idx - safe_limit) - selected = tail_lines[start_idx:end_idx] - data: list[dict] = [] - for line in reversed(selected): - line = line.strip() - if not line: + selected = filtered[start_idx:end_idx] + return list(reversed(selected)), total + + def list_failed_accounts( + self, + *, + limit: int = 200, + start_ts: Optional[float] = None, + end_ts: Optional[float] = None, + ) -> list[dict]: + safe_limit = min(max(int(limit or 200), 1), 500) + with self._lock: + items = self._read_payloads_locked() + + filtered = self._apply_filters( + items, + start_ts=start_ts, + end_ts=end_ts, + failed_only=True, + ) + grouped: dict[str, dict] = {} + for item in filtered: + email = str(item.get("token_account_email") or "").strip() + name = str(item.get("token_account_name") or "").strip() + token_id = str(item.get("token_id") or "").strip() + account_key = email or name or token_id + if not account_key: continue try: - item = json.loads(line) - if isinstance(item, dict): - data.append(item) + ts_val = float(item.get("ts") or 0) + except Exception: + ts_val = 0.0 + bucket = grouped.get(account_key) + if bucket is None: + grouped[account_key] = { + "account_key": account_key, + "token_account_email": email or None, + "token_account_name": name or None, + "token_id": token_id or None, + "failed_count": 1, + "last_ts": ts_val, + } + continue + bucket["failed_count"] = int(bucket.get("failed_count") or 0) + 1 + if ts_val > float(bucket.get("last_ts") or 0): + bucket["last_ts"] = ts_val + if email and not bucket.get("token_account_email"): + bucket["token_account_email"] = email + if name and not bucket.get("token_account_name"): + bucket["token_account_name"] = name + if token_id and not bucket.get("token_id"): + bucket["token_id"] = token_id + + items_out = list(grouped.values()) + items_out.sort( + key=lambda x: ( + -int(x.get("failed_count") or 0), + -float(x.get("last_ts") or 0), + str(x.get("account_key") or "").casefold(), + ) + ) + return items_out[:safe_limit] + + def get(self, request_id: str) -> Optional[dict]: + target = str(request_id or "").strip() + if not target: + return None + with self._lock: + with self._file_path.open("r", encoding="utf-8") as f: + lines = f.readlines() + + fallback = None + attempt_prefix = f"{target}-a" + for line in reversed(lines): + raw = line.strip() + if not raw: + continue + try: + item = json.loads(raw) except Exception: continue - return data, total + if not isinstance(item, dict): + continue + item_id = str(item.get("id") or "").strip() + item_request_id = str(item.get("request_id") or "").strip() + if item_id == target: + payload = dict(item) + payload.setdefault("request_id", target) + return payload + if item_request_id == target: + return dict(item) + if fallback is None and item_id.startswith(attempt_prefix): + payload = dict(item) + payload.setdefault("request_id", target) + payload.setdefault("attempt_id", item_id) + fallback = payload + return fallback def stats( self, @@ -186,46 +400,32 @@ def stats( in_progress_requests = 0 with self._lock: - with self._file_path.open("r", encoding="utf-8") as f: - for line in f: - raw = line.strip() - if not raw: - continue - try: - item = json.loads(raw) - except Exception: - continue - if not isinstance(item, dict): - continue - - try: - ts_val = float(item.get("ts") or 0) - except Exception: - ts_val = 0.0 - if start_ts is not None and ts_val < float(start_ts): - continue - if end_ts is not None and ts_val > float(end_ts): - continue - - total_requests += 1 - - try: - status_code = int(item.get("status_code") or 0) - except Exception: - status_code = 0 - if status_code >= 400: - failed_requests += 1 - - task_status = str(item.get("task_status") or "").upper() - if task_status == "IN_PROGRESS": - in_progress_requests += 1 - - preview_kind = str(item.get("preview_kind") or "").strip().lower() - if 200 <= status_code < 300: - if preview_kind == "image": - generated_images += 1 - elif preview_kind == "video": - generated_videos += 1 + items = self._read_payloads_locked() + + filtered = self._apply_filters(items, start_ts=start_ts, end_ts=end_ts) + for item in filtered: + if not isinstance(item, dict): + continue + + total_requests += 1 + + try: + status_code = int(item.get("status_code") or 0) + except Exception: + status_code = 0 + if status_code >= 400: + failed_requests += 1 + + task_status = str(item.get("task_status") or "").upper() + if task_status == "IN_PROGRESS": + in_progress_requests += 1 + + preview_kind = str(item.get("preview_kind") or "").strip().lower() + if 200 <= status_code < 300: + if preview_kind == "image": + generated_images += 1 + elif preview_kind == "video": + generated_videos += 1 return { "total_requests": total_requests, @@ -348,6 +548,16 @@ def remove(self, item_id: str) -> None: with self._lock: self._items.pop(iid, None) + def get(self, item_id: str) -> Optional[dict]: + iid = str(item_id or "").strip() + if not iid: + return None + with self._lock: + item = self._items.get(iid) + if not isinstance(item, dict): + return None + return dict(item) + def list(self, limit: int = 200) -> list[dict]: safe_limit = min(max(int(limit or 200), 1), 1000) with self._lock: diff --git a/static/admin.css b/static/admin.css index 4498bf8..71eee2e 100644 --- a/static/admin.css +++ b/static/admin.css @@ -48,7 +48,7 @@ body { } .shell { - max-width: 1000px; + max-width: 1180px; margin: 40px auto; padding: 0 20px; display: flex; @@ -457,7 +457,7 @@ table { #logsTable td:nth-child(1), #runningLogsTable th:nth-child(1), #runningLogsTable td:nth-child(1) { - width: 70px; + width: 84px; } #logsTable th:nth-child(2), @@ -480,30 +480,62 @@ table { #logsTable td:nth-child(3), #runningLogsTable th:nth-child(3), #runningLogsTable td:nth-child(3) { - width: 62px; + width: 92px; + min-width: 92px; white-space: nowrap; + text-align: center; +} + +#logsTable th:nth-child(4), +#logsTable td:nth-child(4), +#runningLogsTable th:nth-child(4), +#runningLogsTable td:nth-child(4) { + width: 82px; + min-width: 82px; } #logsTable th:nth-child(5), -#logsTable td:nth-child(5), #runningLogsTable th:nth-child(5), +#logsTable th:nth-child(6), +#runningLogsTable th:nth-child(6) { + white-space: nowrap; +} + +#logsTable td:nth-child(5), #runningLogsTable td:nth-child(5) { - width: 500px; + width: 320px; + min-width: 228px; + padding-left: 20px; } -#logsTable th:nth-child(6), #logsTable td:nth-child(6), -#runningLogsTable th:nth-child(6), #runningLogsTable td:nth-child(6) { - width: 190px; + width: 238px; + min-width: 210px; + white-space: normal; + padding-left: 18px; } #logsTable th:nth-child(7), #logsTable td:nth-child(7), #runningLogsTable th:nth-child(7), #runningLogsTable td:nth-child(7) { - min-width: 220px; - width: 35%; + min-width: 190px; + width: 24%; +} + +#logsTable td:nth-child(7), +#runningLogsTable td:nth-child(7) { + padding-left: 16px; +} + +#logsTable th:nth-child(8), +#logsTable td:nth-child(8), +#runningLogsTable th:nth-child(8), +#runningLogsTable td:nth-child(8) { + width: 104px; + min-width: 104px; + white-space: nowrap; } #logsTable td, @@ -516,6 +548,8 @@ table { display: block; color: #a8bfd8; line-height: 1.35; + max-width: 340px; + overflow-wrap: anywhere; } .log-time-cell { @@ -539,9 +573,28 @@ table { } .log-model-cell { + display: flex; + flex-direction: column; + align-items: flex-start; + gap: 4px; font-family: "IBM Plex Mono", monospace; font-size: 12px; color: #8fb0d3; + white-space: normal; + line-height: 1.3; +} + +.log-model-name { + color: #8fb0d3; + flex: 0 0 auto; + word-break: break-word; +} + +.log-model-meta { + color: #7f96ad; + font-size: 11px; + line-height: 1.35; + flex: 0 0 auto; word-break: break-word; } @@ -551,6 +604,44 @@ table { white-space: normal; word-break: break-word; overflow-wrap: anywhere; + padding-left: 10px !important; +} + +.log-prompt-btn { + appearance: none; + border: 0; + background: transparent; + color: inherit; + font: inherit; + padding: 0; + margin: 0; + cursor: pointer; + text-align: left; + transition: color 0.18s ease, opacity 0.18s ease; +} + +.log-prompt-btn:hover { + color: #d6e7f9; +} + +.log-prompt-btn:focus-visible { + outline: 2px solid rgba(44, 199, 170, 0.55); + outline-offset: 4px; + border-radius: 6px; +} + +.prompt-detail-content { + border: 1px solid rgba(142, 181, 221, 0.25); + border-radius: 8px; + background: #091321; + padding: 14px 16px; + max-height: calc(100vh - 220px); + overflow: auto; + color: #cfe3f8; + font-size: 15px; + line-height: 1.7; + white-space: pre-wrap; + word-break: break-word; } th { @@ -593,6 +684,25 @@ td { .log-status-4xx { background: rgba(225, 163, 44, 0.2); color: #ffca58; } .log-status-5xx { background: rgba(225, 96, 104, 0.2); color: #ffb4bc; } +.logs-filter-bar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; + margin: 0 0 14px; +} + +.logs-filter-checkbox { + min-height: 36px; + padding-right: 4px; + color: #cce0f5; +} + +.logs-filter-bar .input-select { + min-width: 260px; + max-width: 100%; +} + .log-state { display: inline-flex; align-items: center; @@ -894,6 +1004,15 @@ td input[type="checkbox"] { flex-wrap: wrap; gap: 10px; } + + .logs-filter-bar { + align-items: stretch; + } + + .logs-filter-bar .input-select, + .logs-filter-bar button { + width: 100%; + } } @media (max-width: 560px) { diff --git a/static/admin.html b/static/admin.html index 2c43f07..5ab3ab7 100644 --- a/static/admin.html +++ b/static/admin.html @@ -122,21 +122,40 @@

网络与代理设置

-

开启后所有请求将通过下方配置的代理服务器发送,防止 IP 被封禁。

+

基础代理用于发送生成、轮询、刷新积分这类“办理业务”的请求。

- +
+
+ +

资源代理用于下载、上传图片视频这类“搬运业务”的请求。

+
+ +
+ + +
+

默认 300 秒。支持任意正整数,按你的业务场景设置。

+ +
+ +

会先检测基础代理和资源代理的网络连通性,再用当前有效 token 检测基础代理是否真的能访问积分接口。

+
点击上方按钮后,会在这里显示连通性检测和业务权限检测结果。
+
@@ -205,6 +224,35 @@

网络与代理设置

超过上限后按最旧文件优先清理;最新生成文件会被保护,不会被立即删掉。

+
+
+ +

开启后将直接返回上游的 presignedUrl,可减少服务器磁盘占用和本地缓存压力;但该链接通常会过期,历史预览或旧结果可能失效。

+
+
+ +
+
+ +

开启后图片和视频会先上传到图床,再返回图床链接;此模式优先于上游直链和本地文件回链。

+
+
+ + +

参考 CloudFlare ImgBed 上传接口,建议填写完整上传地址。程序会自动追加 authCode 和 returnFormat=full。

+
+
+ + +
+
+
@@ -222,6 +270,8 @@

请求日志

@@ -230,6 +280,19 @@

请求日志

仅展示外部 OpenAI 风格请求日志。失败状态中的状态码可点击查看错误信息。

+
+ + + + +
图片生成数
@@ -259,7 +322,7 @@

请求日志

耗时/秒 进度 账号 - 模型 + 模型/参数 提示词摘要 预览 @@ -348,6 +411,16 @@

错误信息

+ +
diff --git a/static/admin.js b/static/admin.js index fc613d0..2031f5f 100644 --- a/static/admin.js +++ b/static/admin.js @@ -658,6 +658,10 @@ document.addEventListener("DOMContentLoaded", async () => { const confPublicBaseUrl = document.getElementById("confPublicBaseUrl"); const confUseProxy = document.getElementById("confUseProxy"); const confProxy = document.getElementById("confProxy"); + const confResourceUseProxy = document.getElementById("confResourceUseProxy"); + const confResourceProxy = document.getElementById("confResourceProxy"); + const testProxyBtn = document.getElementById("testProxyBtn"); + const proxyTestResult = document.getElementById("proxyTestResult"); const confGenerateTimeout = document.getElementById("confGenerateTimeout"); const confRetryEnabled = document.getElementById("confRetryEnabled"); const confRetryMaxAttempts = document.getElementById("confRetryMaxAttempts"); @@ -669,6 +673,10 @@ document.addEventListener("DOMContentLoaded", async () => { const confBatchConcurrency = document.getElementById("confBatchConcurrency"); const confGeneratedMaxSizeMb = document.getElementById("confGeneratedMaxSizeMb"); const confGeneratedPruneSizeMb = document.getElementById("confGeneratedPruneSizeMb"); + const confUseUpstreamResultUrl = document.getElementById("confUseUpstreamResultUrl"); + const confImgBedEnabled = document.getElementById("confImgBedEnabled"); + const confImgBedApiUrl = document.getElementById("confImgBedApiUrl"); + const confImgBedApiKey = document.getElementById("confImgBedApiKey"); const generatedUsageInfo = document.getElementById("generatedUsageInfo"); const configCatBtns = document.querySelectorAll(".config-cat-btn"); const configCatPanes = document.querySelectorAll(".config-cat-pane"); @@ -692,6 +700,9 @@ document.addEventListener("DOMContentLoaded", async () => { const logsPrevBtn = document.getElementById("logsPrevBtn"); const logsNextBtn = document.getElementById("logsNextBtn"); const logsPageInfo = document.getElementById("logsPageInfo"); + const logsFailedOnly = document.getElementById("logsFailedOnly"); + const logsMediaKind = document.getElementById("logsMediaKind"); + const clearLogFiltersBtn = document.getElementById("clearLogFiltersBtn"); const previewModal = document.getElementById("previewModal"); const previewContent = document.getElementById("previewContent"); const previewCloseBtn = document.getElementById("previewCloseBtn"); @@ -700,12 +711,82 @@ document.addEventListener("DOMContentLoaded", async () => { const errorDetailCode = document.getElementById("errorDetailCode"); const errorDetailContent = document.getElementById("errorDetailContent"); const errorDetailCloseBtn = document.getElementById("errorDetailCloseBtn"); + const promptDetailModal = document.getElementById("promptDetailModal"); + const promptDetailContent = document.getElementById("promptDetailContent"); + const promptDetailCloseBtn = document.getElementById("promptDetailCloseBtn"); const appToast = document.getElementById("appToast"); const LOGS_PAGE_SIZE = 20; let logsCurrentPage = 1; let logsTotalPages = 1; let logsRunningTotal = 0; + function getSelectedLogMediaKind() { + return String(logsMediaKind?.value || "").trim().toLowerCase(); + } + + function isFailedOnlyFilterEnabled() { + return Boolean(logsFailedOnly?.checked); + } + + function getLogsQueryParams() { + const params = new URLSearchParams(); + params.set("limit", String(LOGS_PAGE_SIZE)); + params.set("page", String(logsCurrentPage)); + if (isFailedOnlyFilterEnabled()) { + params.set("failed_only", "true"); + } + const mediaKind = getSelectedLogMediaKind(); + if (mediaKind) { + params.set("media_kind", mediaKind); + } + return params; + } + + function matchesLogMediaKind(item, mediaKind) { + const target = String(mediaKind || "").trim().toLowerCase(); + if (!target) return true; + return resolveLogMediaKind(item) === target; + } + + function resolveLogMediaKind(item) { + const previewKind = String(item?.preview_kind || "").trim().toLowerCase(); + if (previewKind === "image" || previewKind === "video") return previewKind; + + const model = String(item?.model || "").trim().toLowerCase(); + if (model) { + if ( + model.includes("sora") || + model.includes("veo") || + model.includes("video") || + model.includes("text2video") + ) { + return "video"; + } + return "image"; + } + + const path = String(item?.path || "").trim().toLowerCase(); + const operation = String(item?.operation || "").trim().toLowerCase(); + if (path.endsWith("/v1/images/generations") || operation === "images.generations") { + return "image"; + } + if (path.endsWith("/v1/chat/completions") || operation === "chat.completions") { + return "image"; + } + return ""; + } + + if (testProxyBtn) { + testProxyBtn.textContent = "检测代理与业务权限"; + const proxyHelp = testProxyBtn.nextElementSibling; + if (proxyHelp && proxyHelp.classList.contains("help")) { + proxyHelp.textContent = "会先检测基础代理和资源代理的网络连通性,再用当前有效 token 检测基础代理是否真的能访问积分接口。检测时会直接使用你当前表单里的值,不需要先保存配置。"; + } + } + if (proxyTestResult && !String(proxyTestResult.textContent || "").trim()) { + proxyTestResult.textContent = "点击上方按钮后,会在这里显示连通性检测和业务权限检测结果。"; + } + function switchConfigPane(targetId) { if (!targetId) return; configCatBtns.forEach((btn) => { @@ -742,6 +823,8 @@ document.addEventListener("DOMContentLoaded", async () => { confPublicBaseUrl.value = data.public_base_url || ""; confUseProxy.checked = data.use_proxy || false; confProxy.value = data.proxy || ""; + confResourceUseProxy.checked = data.resource_use_proxy || false; + confResourceProxy.value = data.resource_proxy || ""; confGenerateTimeout.value = Number(data.generate_timeout || 300); confRetryEnabled.checked = Boolean(data.retry_enabled ?? true); confRetryMaxAttempts.value = Number(data.retry_max_attempts || 3); @@ -758,6 +841,10 @@ document.addEventListener("DOMContentLoaded", async () => { confBatchConcurrency.value = currentBatchConcurrency; confGeneratedMaxSizeMb.value = Number(data.generated_max_size_mb || 1024); confGeneratedPruneSizeMb.value = Number(data.generated_prune_size_mb || 200); + confUseUpstreamResultUrl.checked = Boolean(data.use_upstream_result_url || false); + confImgBedEnabled.checked = Boolean(data.imgbed_enabled || false); + confImgBedApiUrl.value = data.imgbed_api_url || ""; + confImgBedApiKey.value = data.imgbed_api_key || ""; if (generatedUsageInfo) { const usageMb = Number(data.generated_usage_mb || 0); const fileCount = Number(data.generated_file_count || 0); @@ -784,6 +871,8 @@ document.addEventListener("DOMContentLoaded", async () => { public_base_url: confPublicBaseUrl.value.trim(), use_proxy: confUseProxy.checked, proxy: confProxy.value.trim(), + resource_use_proxy: confResourceUseProxy.checked, + resource_proxy: confResourceProxy.value.trim(), generate_timeout: Math.max(1, Number(confGenerateTimeout.value || 300)), retry_enabled: confRetryEnabled.checked, retry_max_attempts: Math.max(1, Math.min(10, Number(confRetryMaxAttempts.value || 3))), @@ -801,6 +890,10 @@ document.addEventListener("DOMContentLoaded", async () => { batch_concurrency: Math.max(1, Math.min(100, Number(confBatchConcurrency.value || 5))), generated_max_size_mb: Math.max(100, Math.min(102400, Number(confGeneratedMaxSizeMb.value || 1024))), generated_prune_size_mb: Math.max(10, Math.min(10240, Number(confGeneratedPruneSizeMb.value || 200))), + use_upstream_result_url: confUseUpstreamResultUrl.checked, + imgbed_enabled: confImgBedEnabled.checked, + imgbed_api_url: confImgBedApiUrl.value.trim(), + imgbed_api_key: confImgBedApiKey.value.trim(), }; if (!payload.admin_username) { @@ -825,6 +918,20 @@ document.addEventListener("DOMContentLoaded", async () => { if (payload.generated_prune_size_mb >= payload.generated_max_size_mb) { throw new Error("触发后清理量必须小于生成文件空间上限"); } + if (payload.use_proxy && !/^https?:\/\//i.test(payload.proxy)) { + throw new Error("基础代理地址必须以 http:// 或 https:// 开头"); + } + if (payload.resource_use_proxy && !/^https?:\/\//i.test(payload.resource_proxy)) { + throw new Error("资源代理地址必须以 http:// 或 https:// 开头"); + } + if (payload.imgbed_enabled) { + if (!/^https?:\/\//i.test(payload.imgbed_api_url)) { + throw new Error("图床 API 地址必须以 http:// 或 https:// 开头"); + } + if (!payload.imgbed_api_key) { + throw new Error("开启图传模式时,图床密钥不能为空"); + } + } if (!Number.isInteger(payload.retry_max_attempts) || payload.retry_max_attempts < 1 || payload.retry_max_attempts > 10) { throw new Error("最大尝试次数必须是 1-10 的整数"); } @@ -855,6 +962,155 @@ document.addEventListener("DOMContentLoaded", async () => { saveConfigBtn.disabled = false; }); + function formatProxyConnectivityItem(title, item) { + const data = item && typeof item === "object" ? item : {}; + const enabled = Boolean(data.enabled); + const statusCode = data.status_code == null ? null : Number(data.status_code); + let statusText = "连接失败"; + if (!enabled) { + statusText = "未启用"; + } else if (Boolean(data.ok)) { + statusText = "连接成功"; + } else if (statusCode != null) { + statusText = "目标已响应"; + } + const elapsedText = Number.isFinite(Number(data.elapsed_ms)) + ? `${Number(data.elapsed_ms)} ms` + : "-"; + const statusCodeText = statusCode == null ? "-" : String(statusCode); + const proxyText = String(data.proxy || "").trim() || "未填写"; + const targetText = String(data.target_url || "").trim() || "-"; + let messageText = String(data.message || "").trim() || "-"; + if (enabled && statusCode != null && [401, 403].includes(statusCode)) { + messageText = "已收到上游响应,说明代理链路是通的;当前检测请求本身没有业务权限。"; + } + return [ + `${title}`, + `状态:${statusText}`, + `代理地址:${proxyText}`, + `检测目标:${targetText}`, + `耗时:${elapsedText}`, + `HTTP 状态码:${statusCodeText}`, + `详细信息:${messageText}`, + ].join("\n"); + } + + function formatProxyBusinessItem(title, item) { + const data = item && typeof item === "object" ? item : {}; + const enabled = Boolean(data.enabled); + const hasToken = Boolean(String(data.token_id || "").trim()); + const statusCode = data.status_code == null ? null : Number(data.status_code); + let statusText = "检测失败"; + if (!enabled) { + statusText = "未启用"; + } else if (!hasToken) { + statusText = "未执行"; + } else if (Boolean(data.ok)) { + statusText = "权限检测成功"; + } else if (statusCode != null) { + statusText = "权限检测失败"; + } + const elapsedText = Number.isFinite(Number(data.elapsed_ms)) + ? `${Number(data.elapsed_ms)} ms` + : "-"; + const statusCodeText = statusCode == null ? "-" : String(statusCode); + const tokenIdText = String(data.token_id || "").trim() || "-"; + const tokenSourceText = String(data.token_source || "").trim() || "-"; + const tokenPreviewText = String(data.token_preview || "").trim() || "-"; + const accountIdText = String(data.account_id || "").trim() || "-"; + const messageText = String(data.message || "").trim() || "-"; + return [ + `${title}`, + `状态:${statusText}`, + `检测目标:${String(data.target_url || "").trim() || "-"}`, + `耗时:${elapsedText}`, + `HTTP 状态码:${statusCodeText}`, + `Token ID:${tokenIdText}`, + `Token 来源:${tokenSourceText}`, + `Token 预览:${tokenPreviewText}`, + `Account ID:${accountIdText}`, + `详细信息:${messageText}`, + ].join("\n"); + } + + function formatProxyTestResult(payload) { + const data = payload && typeof payload === "object" ? payload : {}; + const connectivity = data.connectivity && typeof data.connectivity === "object" + ? data.connectivity + : data; + const business = data.business && typeof data.business === "object" + ? data.business + : {}; + const connectivitySections = [ + formatProxyConnectivityItem("基础代理", connectivity.basic), + formatProxyConnectivityItem("资源代理", connectivity.resource), + ]; + const businessSections = [ + formatProxyBusinessItem("基础代理业务权限", business.basic), + ]; + return [ + "代理检测结果", + "", + "一、连通性检测", + connectivitySections.join("\n\n"), + "", + "二、业务权限检测", + businessSections.join("\n\n"), + ].join("\n"); + } + + async function handleProxyTest() { + if (proxyTestResult) { + proxyTestResult.textContent = "正在检测代理连通性和业务权限,请稍候..."; + } + const payload = { + use_proxy: confUseProxy.checked, + proxy: confProxy.value.trim(), + resource_use_proxy: confResourceUseProxy.checked, + resource_proxy: confResourceProxy.value.trim(), + }; + if (payload.use_proxy && !/^https?:\/\//i.test(payload.proxy)) { + throw new Error("基础代理地址必须以 http:// 或 https:// 开头"); + } + if (payload.resource_use_proxy && !/^https?:\/\//i.test(payload.resource_proxy)) { + throw new Error("资源代理地址必须以 http:// 或 https:// 开头"); + } + const res = await fetch("/api/v1/proxy/test", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }); + const data = await res.json(); + if (!res.ok) { + throw new Error(data.detail || "代理与业务权限检测失败"); + } + if (proxyTestResult) { + proxyTestResult.textContent = formatProxyTestResult(data); + } + showToast("代理与业务权限检测已完成", false); + } + + if (testProxyBtn) { + testProxyBtn.addEventListener("click", async () => { + testProxyBtn.disabled = true; + if (proxyTestResult) { + proxyTestResult.textContent = "正在检测代理连通性和业务权限,请稍候..."; + } + try { + await handleProxyTest(); + } catch (err) { + if (proxyTestResult) { + proxyTestResult.textContent = String( + err?.message || err || "代理与业务权限检测失败" + ); + } + showToast(err.message || "代理与业务权限检测失败", true); + } finally { + testProxyBtn.disabled = false; + } + }); + } + function formatTs(ts) { if (!ts) return "-"; const d = new Date(Number(ts) * 1000); @@ -871,6 +1127,14 @@ document.addEventListener("DOMContentLoaded", async () => { .replace(/'/g, "'"); } + function buildPromptSummary(value) { + const raw = String(value || "").trim(); + if (!raw) return "-"; + const chars = Array.from(raw); + if (chars.length <= 4) return raw; + return `${chars.slice(0, 4).join("")}...`; + } + function truncateText(value, maxLen) { const text = String(value || ""); if (text.length <= maxLen) return text; @@ -1193,9 +1457,10 @@ document.addEventListener("DOMContentLoaded", async () => { if (!logsTbody) return; try { const rangeValue = logStatsRange ? String(logStatsRange.value || "today") : "today"; + const logParams = getLogsQueryParams(); const [runningResult, logsResult, statsResult] = await Promise.allSettled([ fetch("/api/v1/logs/running?limit=200"), - fetch(`/api/v1/logs?limit=${LOGS_PAGE_SIZE}&page=${logsCurrentPage}`), + fetch(`/api/v1/logs?${logParams.toString()}`), fetch(`/api/v1/logs/stats?range=${encodeURIComponent(rangeValue)}`), ]); @@ -1321,18 +1586,28 @@ document.addEventListener("DOMContentLoaded", async () => { : `` ); const modelText = String(item.model || "-"); + const modelParamsText = String(item.model_params || "").trim(); + const promptText = String(item.prompt_preview || "").trim(); + const promptSummary = buildPromptSummary(promptText); const tokenCell = ``; const previewCell = previewUrl ? `` : `-`; + const modelTitle = escapeHtml([modelText, modelParamsText].filter(Boolean).join(" | ")); + const modelCell = ` +
+ ${escapeHtml(modelText)} + ${modelParamsText ? `${escapeHtml(modelParamsText)}` : ""} +
+ `; tr.innerHTML = ` ${dateText}${timeText} ${statusCell} ${t} ${progressCell} ${tokenCell} - ${escapeHtml(modelText)} - ${item.prompt_preview || "-"} + ${modelCell} + ${promptText ? `` : "-"} ${previewCell} `; if (isRunning) tr.classList.add("log-row-running"); @@ -1344,7 +1619,12 @@ document.addEventListener("DOMContentLoaded", async () => { clearTimeout(logsAutoTimer); logsAutoTimer = null; } - const runningRows = Array.isArray(runningItems) ? runningItems : []; + const selectedMediaKind = getSelectedLogMediaKind(); + const runningRows = isFailedOnlyFilterEnabled() + ? [] + : (Array.isArray(runningItems) ? runningItems : []).filter((item) => + matchesLogMediaKind(item, selectedMediaKind) + ); logsRunningTotal = runningRows.length; const allRows = [ ...runningRows, @@ -1418,6 +1698,13 @@ document.addEventListener("DOMContentLoaded", async () => { errorDetailContent.innerHTML = ""; } + function closePromptDetail() { + if (!promptDetailModal || !promptDetailContent) return; + promptDetailModal.classList.remove("open"); + promptDetailModal.setAttribute("aria-hidden", "true"); + promptDetailContent.textContent = ""; + } + async function openErrorDetailByCode(code) { const errCode = String(code || "").trim(); if (!errCode || !errorDetailModal || !errorDetailCode || !errorDetailContent) return; @@ -1467,10 +1754,23 @@ document.addEventListener("DOMContentLoaded", async () => { previewModal.setAttribute("aria-hidden", "false"); } + function openPromptDetail(text) { + if (!promptDetailModal || !promptDetailContent) return; + promptDetailContent.textContent = String(text || "").trim() || "暂无提示词"; + promptDetailModal.classList.add("open"); + promptDetailModal.setAttribute("aria-hidden", "false"); + } + if (logsTbody) { logsTbody.addEventListener("click", (event) => { const target = event.target; if (!(target instanceof HTMLElement)) return; + const promptBtn = target.closest("[data-full-prompt]"); + if (promptBtn instanceof HTMLElement) { + const fullPrompt = String(promptBtn.getAttribute("data-full-prompt") || "").trim(); + openPromptDetail(decodeURIComponent(fullPrompt)); + return; + } if (target.classList.contains("preview-btn")) { const encodedUrl = target.getAttribute("data-url") || ""; const kind = (target.getAttribute("data-kind") || "").trim(); @@ -1507,10 +1807,21 @@ document.addEventListener("DOMContentLoaded", async () => { }); } + if (promptDetailCloseBtn) { + promptDetailCloseBtn.addEventListener("click", closePromptDetail); + } + + if (promptDetailModal) { + promptDetailModal.addEventListener("click", (event) => { + if (event.target === promptDetailModal) closePromptDetail(); + }); + } + document.addEventListener("keydown", (event) => { if (event.key === "Escape") { closePreview(); closeErrorDetail(); + closePromptDetail(); closeDialog(tokenModal); closeDialog(refreshModal); } @@ -1523,6 +1834,29 @@ document.addEventListener("DOMContentLoaded", async () => { }); } + if (logsFailedOnly) { + logsFailedOnly.addEventListener("change", () => { + logsCurrentPage = 1; + loadLogs(); + }); + } + + if (logsMediaKind) { + logsMediaKind.addEventListener("change", () => { + logsCurrentPage = 1; + loadLogs(); + }); + } + + if (clearLogFiltersBtn) { + clearLogFiltersBtn.addEventListener("click", () => { + if (logsFailedOnly) logsFailedOnly.checked = false; + if (logsMediaKind) logsMediaKind.value = ""; + logsCurrentPage = 1; + loadLogs(); + }); + } + if (logStatsRange) { logStatsRange.addEventListener("change", () => { logsCurrentPage = 1;