Spaces:
Sleeping
Sleeping
opencode-ai commited on
Commit ·
b1e4ece
1
Parent(s): dddf47b
Latest grok2api code, keep old deps (fast build)
Browse files- README.md +592 -47
- app/control/account/quota_defaults.py +44 -7
- app/control/account/refresh.py +16 -3
- app/control/account/scheduler.py +2 -2
- app/control/model/registry.py +6 -6
- app/control/model/spec.py +4 -1
- app/dataplane/account/__init__.py +1 -1
- app/dataplane/account/sync.py +2 -1
- app/dataplane/reverse/protocol/xai_chat.py +42 -0
- app/dataplane/reverse/protocol/xai_image_edit.py +0 -1
- app/dataplane/reverse/protocol/xai_usage.py +11 -6
- app/dataplane/reverse/transport/assets.py +26 -18
- app/dataplane/reverse/transport/http.py +3 -1
- app/platform/startup/migration.py +61 -1
- app/products/openai/chat.py +18 -4
- app/products/openai/images.py +191 -70
- app/products/openai/router.py +7 -3
- app/products/openai/video.py +61 -18
- app/products/web/admin/tokens.py +68 -0
- app/products/web/webui/voice.py +6 -3
- app/statics/admin/account.html +49 -2
- app/statics/admin/config.html +10 -2
- app/statics/css/app.css +7 -0
- app/statics/i18n/de.json +15 -2
- app/statics/i18n/en.json +18 -2
- app/statics/i18n/es.json +15 -2
- app/statics/i18n/fr.json +15 -2
- app/statics/i18n/ja.json +15 -2
- app/statics/i18n/zh.json +18 -2
- app/statics/js/webui/chat.js +61 -1
- config.defaults.toml +3 -1
README.md
CHANGED
|
@@ -1,64 +1,609 @@
|
|
| 1 |
-
---
|
| 2 |
-
title: Grok2API
|
| 3 |
-
emoji: 🤖
|
| 4 |
-
colorFrom: blue
|
| 5 |
-
colorTo: red
|
| 6 |
-
sdk: docker
|
| 7 |
-
app_port: 7860
|
| 8 |
-
pinned: false
|
| 9 |
-
description: Grok2API - A FastAPI-based Grok gateway that converts Grok web capabilities to OpenAI-compatible API.
|
| 10 |
-
---
|
| 11 |
|
| 12 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 13 |
|
| 14 |
-
A FastAPI-based Grok gateway that converts Grok web capabilities to OpenAI-compatible API.
|
| 15 |
|
| 16 |
-
|
|
|
|
| 17 |
|
| 18 |
-
|
| 19 |
|
| 20 |
-
|
| 21 |
-
-
|
| 22 |
-
-
|
| 23 |
-
-
|
| 24 |
-
-
|
| 25 |
-
-
|
|
|
|
|
|
|
| 26 |
|
| 27 |
-
|
| 28 |
|
| 29 |
-
|
| 30 |
-
- Server port: 7860 (HF Spaces default)
|
| 31 |
-
- Data directory: `/data` (mounted from HF Dataset)
|
| 32 |
-
- Logs directory: `/data/logs`
|
| 33 |
|
| 34 |
-
|
|
|
|
|
|
|
| 35 |
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
|
|
|
|
|
|
|
|
|
| 39 |
|
| 40 |
-
|
| 41 |
-
|
| 42 |
-
|
| 43 |
-
|
|
|
|
|
|
|
| 44 |
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
-
|
| 48 |
-
|
| 49 |
-
|
| 50 |
-
|
| 51 |
-
|
|
|
|
|
|
|
|
|
|
| 52 |
|
| 53 |
-
|
|
|
|
|
|
|
| 54 |
|
| 55 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 56 |
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
|
|
|
|
|
|
|
| 60 |
|
| 61 |
-
|
|
|
|
|
|
|
| 62 |
|
| 63 |
-
|
| 64 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<img alt="Grok2API" src="https://github.com/user-attachments/assets/037a0a6e-7986-41cc-b4af-04df612ee886" />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2 |
|
| 3 |
+
[](https://www.python.org/)
|
| 4 |
+
[](https://fastapi.tiangolo.com/)
|
| 5 |
+
[](pyproject.toml)
|
| 6 |
+
[](LICENSE)
|
| 7 |
+
[](docs/README.en.md)
|
| 8 |
+
[](https://deepwiki.com/chenyme/grok2api)
|
| 9 |
+
[](https://blog.cheny.me/blog/posts/grok2api)
|
| 10 |
|
|
|
|
| 11 |
|
| 12 |
+
> [!NOTE]
|
| 13 |
+
> 本项目仅供学习与研究交流。请务必遵循 Grok 的使用条款及当地法律法规,不得用于非法用途!二开与 PR 请保留原作者与前端标识。
|
| 14 |
|
| 15 |
+
<br>
|
| 16 |
|
| 17 |
+
Grok2API 是一个基于 **FastAPI** 构建的 Grok 网关,支持将 Grok Web 能力以 OpenAI 兼容 API 的方式转换。核心特性:
|
| 18 |
+
- OpenAI 兼容接口:`/v1/models`、`/v1/chat/completions`、`/v1/responses`、`/v1/images/generations`、`/v1/images/edits`、`/v1/videos`、`/v1/videos/{video_id}`、`/v1/videos/{video_id}/content`
|
| 19 |
+
- Anthropic 兼容接口:`/v1/messages`
|
| 20 |
+
- 支持流式与非流式对话、显式思考输出、函数工具结构透传,以及统一的 token / usage 统计
|
| 21 |
+
- 支持多账号池、层级选号、失败反馈、额度同步与自动维护
|
| 22 |
+
- 支持本地缓存图片、视频与本地代理链接返回
|
| 23 |
+
- 支持文生图、图像编辑、文生视频、图生视频
|
| 24 |
+
- 内置 Admin 后台管理、Web Chat、Masonry 生图、ChatKit 语音页面
|
| 25 |
|
| 26 |
+
<br>
|
| 27 |
|
| 28 |
+
## 服务架构
|
|
|
|
|
|
|
|
|
|
| 29 |
|
| 30 |
+
```mermaid
|
| 31 |
+
flowchart LR
|
| 32 |
+
Client["Clients\nOpenAI SDK / curl / Browser"] --> API["FastAPI App"]
|
| 33 |
|
| 34 |
+
subgraph Products["Products"]
|
| 35 |
+
direction TB
|
| 36 |
+
OpenAI["OpenAI APIs\n/v1/*"]
|
| 37 |
+
Anthropic["Anthropic APIs\n/v1/messages"]
|
| 38 |
+
Web["Web Products\n/admin /webui/*"]
|
| 39 |
+
end
|
| 40 |
|
| 41 |
+
subgraph Control["Control"]
|
| 42 |
+
direction TB
|
| 43 |
+
Models["Model Registry"]
|
| 44 |
+
Accounts["Account Services"]
|
| 45 |
+
Proxies["Proxy Services"]
|
| 46 |
+
end
|
| 47 |
|
| 48 |
+
subgraph Dataplane["Dataplane"]
|
| 49 |
+
direction TB
|
| 50 |
+
Reverse["Reverse Protocol + Transport"]
|
| 51 |
+
AccountDP["AccountDirectory"]
|
| 52 |
+
ProxyDP["Proxy Runtime"]
|
| 53 |
+
end
|
| 54 |
|
| 55 |
+
subgraph Platform["Platform"]
|
| 56 |
+
direction TB
|
| 57 |
+
Tokens["Token Estimation"]
|
| 58 |
+
Storage["Storage"]
|
| 59 |
+
Config["Config Snapshot"]
|
| 60 |
+
Auth["Auth"]
|
| 61 |
+
Log["Logging"]
|
| 62 |
+
end
|
| 63 |
|
| 64 |
+
API --> OpenAI
|
| 65 |
+
API --> Anthropic
|
| 66 |
+
API --> Web
|
| 67 |
|
| 68 |
+
OpenAI --> Models
|
| 69 |
+
OpenAI --> AccountDP
|
| 70 |
+
OpenAI --> ProxyDP
|
| 71 |
+
OpenAI --> Reverse
|
| 72 |
+
OpenAI --> Tokens
|
| 73 |
+
OpenAI --> Storage
|
| 74 |
|
| 75 |
+
Anthropic --> Models
|
| 76 |
+
Anthropic --> AccountDP
|
| 77 |
+
Anthropic --> ProxyDP
|
| 78 |
+
Anthropic --> Reverse
|
| 79 |
+
Anthropic --> Tokens
|
| 80 |
|
| 81 |
+
Web --> Accounts
|
| 82 |
+
Web --> Config
|
| 83 |
+
Web --> Auth
|
| 84 |
|
| 85 |
+
Accounts --> AccountDP
|
| 86 |
+
Proxies --> ProxyDP
|
| 87 |
+
Models --> Reverse
|
| 88 |
+
```
|
| 89 |
+
|
| 90 |
+
<br>
|
| 91 |
+
|
| 92 |
+
## 快速开始
|
| 93 |
+
|
| 94 |
+
### 本地部署
|
| 95 |
+
|
| 96 |
+
```bash
|
| 97 |
+
git clone https://github.com/chenyme/grok2api
|
| 98 |
+
cd grok2api
|
| 99 |
+
cp .env.example .env
|
| 100 |
+
uv sync
|
| 101 |
+
uv run granian --interface asgi --host 0.0.0.0 --port 8000 --workers 1 app.main:app
|
| 102 |
+
```
|
| 103 |
+
|
| 104 |
+
### Docker Compose
|
| 105 |
+
|
| 106 |
+
```bash
|
| 107 |
+
git clone https://github.com/chenyme/grok2api
|
| 108 |
+
cd grok2api
|
| 109 |
+
cp .env.example .env
|
| 110 |
+
docker compose up -d
|
| 111 |
+
```
|
| 112 |
+
|
| 113 |
+
### Vercel
|
| 114 |
+
|
| 115 |
+
[](https://vercel.com/new/clone?repository-url=https://github.com/chenyme/grok2api&env=LOG_LEVEL,LOG_FILE_ENABLED,DATA_DIR,LOG_DIR,ACCOUNT_STORAGE,ACCOUNT_REDIS_URL,ACCOUNT_MYSQL_URL,ACCOUNT_POSTGRESQL_URL)
|
| 116 |
+
|
| 117 |
+
### Render
|
| 118 |
+
|
| 119 |
+
[](https://render.com/deploy?repo=https://github.com/chenyme/grok2api)
|
| 120 |
+
|
| 121 |
+
### 首次启动
|
| 122 |
+
|
| 123 |
+
1. 修改 `app.app_key`
|
| 124 |
+
2. 设置 `app.api_key`
|
| 125 |
+
3. 设置 `app.app_url`(否则图片、视频的链接会 403 无权访问)
|
| 126 |
+
|
| 127 |
+
<br>
|
| 128 |
+
|
| 129 |
+
## WebUI
|
| 130 |
+
|
| 131 |
+
### 页面入口
|
| 132 |
+
|
| 133 |
+
| 页面 | 路径 |
|
| 134 |
+
| :-- | :-- |
|
| 135 |
+
| Admin 登录页 | `/admin/login` |
|
| 136 |
+
| 账号管理 | `/admin/account` |
|
| 137 |
+
| 配置管理 | `/admin/config` |
|
| 138 |
+
| 缓存管理 | `/admin/cache` |
|
| 139 |
+
| WebUI 登录页 | `/webui/login` |
|
| 140 |
+
| Web Chat | `/webui/chat` |
|
| 141 |
+
| Masonry | `/webui/masonry` |
|
| 142 |
+
| ChatKit | `/webui/chatkit` |
|
| 143 |
+
|
| 144 |
+
### 鉴权规则
|
| 145 |
+
|
| 146 |
+
| 范围 | 配置项 | 规则 |
|
| 147 |
+
| :-- | :-- | :-- |
|
| 148 |
+
| `/v1/*` | `app.api_key` | 为空则不额外鉴权 |
|
| 149 |
+
| `/admin/*` | `app.app_key` | 默认值 `grok2api` |
|
| 150 |
+
| `/webui/*` | `app.webui_enabled`, `app.webui_key` | 默认关闭;`webui_key` 为空则不额外校验 |
|
| 151 |
+
|
| 152 |
+
<br>
|
| 153 |
+
|
| 154 |
+
## 配置体系
|
| 155 |
+
|
| 156 |
+
### 配置分层
|
| 157 |
+
|
| 158 |
+
| 位置 | 用途 | 生效时机 |
|
| 159 |
+
| :-- | :-- | :-- |
|
| 160 |
+
| `.env` | 启动前配置 | 服务启动时 |
|
| 161 |
+
| `${DATA_DIR}/config.toml` | 运行时配置 | 保存后即时生效 |
|
| 162 |
+
| `config.defaults.toml` | 默认模板 | 首次初始化时 |
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
### 环境变量
|
| 167 |
+
|
| 168 |
+
| 变量名 | 说明 | 默认值 |
|
| 169 |
+
| :-- | :-- | :-- |
|
| 170 |
+
| `TZ` | 时区 | `Asia/Shanghai` |
|
| 171 |
+
| `LOG_LEVEL` | 日志级别 | `INFO` |
|
| 172 |
+
| `LOG_FILE_ENABLED` | 写入本地文件日志 | `true` |
|
| 173 |
+
| `ACCOUNT_SYNC_INTERVAL` | 账号目录增量同步间隔(秒) | `30` |
|
| 174 |
+
| `ACCOUNT_SYNC_ACTIVE_INTERVAL` | 账号目录检测到变化后的活跃同步间隔(秒) | `3` |
|
| 175 |
+
| `SERVER_HOST` | 服务监听地址 | `0.0.0.0` |
|
| 176 |
+
| `SERVER_PORT` | 服务监听端口 | `8000` |
|
| 177 |
+
| `SERVER_WORKERS` | Granian worker 数量 | `1` |
|
| 178 |
+
| `HOST_PORT` | Docker Compose 宿主机映射端口 | `8000` |
|
| 179 |
+
| `DATA_DIR` | 本地数据根目录(账号库、本地媒体文件、缓存索引统一位于此目录下) | `./data` |
|
| 180 |
+
| `LOG_DIR` | 本地日志目录 | `./logs` |
|
| 181 |
+
| `ACCOUNT_STORAGE` | 账号存储后端 | `local` |
|
| 182 |
+
| `ACCOUNT_LOCAL_PATH` | `local` 模式账号 SQLite 路径 | `${DATA_DIR}/accounts.db` |
|
| 183 |
+
| `ACCOUNT_REDIS_URL` | `redis` 模式 Redis DSN | `""` |
|
| 184 |
+
| `ACCOUNT_MYSQL_URL` | `mysql` 模式 SQLAlchemy DSN | `""` |
|
| 185 |
+
| `ACCOUNT_POSTGRESQL_URL` | `postgresql` 模式 SQLAlchemy DSN | `""` |
|
| 186 |
+
| `ACCOUNT_SQL_POOL_SIZE` | SQL 连接池核心连接数 | `5` |
|
| 187 |
+
| `ACCOUNT_SQL_MAX_OVERFLOW` | SQL 连接池最大溢出连接数 | `10` |
|
| 188 |
+
| `ACCOUNT_SQL_POOL_TIMEOUT` | 等待连接池空闲连接的超时时间(秒) | `30` |
|
| 189 |
+
| `ACCOUNT_SQL_POOL_RECYCLE` | 连接最大复用时间(秒),超时后自动重连 | `1800` |
|
| 190 |
+
| `CONFIG_LOCAL_PATH` | `local` 模式运行时配置文件路径 | `${DATA_DIR}/config.toml` |
|
| 191 |
+
|
| 192 |
+
运行时配置也支持 `GROK_` 前缀环境变量覆盖,例如 `GROK_APP_API_KEY` 会覆盖 `app.api_key`,`GROK_FEATURES_STREAM` 会覆盖 `features.stream`。
|
| 193 |
+
|
| 194 |
+
### 系统配置项
|
| 195 |
+
|
| 196 |
+
| 分组 | 关键项 |
|
| 197 |
+
| :-- | :-- |
|
| 198 |
+
| `app` | `app_key`, `app_url`, `api_key`, `webui_enabled`, `webui_key` |
|
| 199 |
+
| `logging` | `file_level`, `max_files` |
|
| 200 |
+
| `features` | `temporary`, `memory`, `stream`, `thinking`, `auto_chat_mode_fallback`, `thinking_summary`, `dynamic_statsig`, `enable_nsfw`, `show_search_sources`, `custom_instruction`, `image_format`, `imagine_public_image_proxy`, `video_format` |
|
| 201 |
+
| `proxy.egress` | `mode`, `proxy_url`, `proxy_pool`, `resource_proxy_url`, `resource_proxy_pool`, `skip_ssl_verify` |
|
| 202 |
+
| `proxy.clearance` | `mode`, `cf_cookies`, `user_agent`, `browser`, `flaresolverr_url`, `timeout_sec`, `refresh_interval` |
|
| 203 |
+
| `retry` | `reset_session_status_codes`, `max_retries`, `on_codes` |
|
| 204 |
+
| `account.refresh` | `basic_interval_sec`, `super_interval_sec`, `heavy_interval_sec`, `usage_concurrency`, `on_demand_min_interval_sec` |
|
| 205 |
+
| `cache.local` | `image_max_mb`, `video_max_mb` |
|
| 206 |
+
| `chat` | `timeout` |
|
| 207 |
+
| `image` | `timeout`, `stream_timeout` |
|
| 208 |
+
| `video` | `timeout` |
|
| 209 |
+
| `voice` | `timeout` |
|
| 210 |
+
| `asset` | `upload_timeout`, `download_timeout`, `list_timeout`, `delete_timeout` |
|
| 211 |
+
| `nsfw` | `timeout` |
|
| 212 |
+
| `batch` | `nsfw_concurrency`, `refresh_concurrency`, `asset_upload_concurrency`, `asset_list_concurrency`, `asset_delete_concurrency` |
|
| 213 |
+
|
| 214 |
+
### 图片、视频格式
|
| 215 |
+
|
| 216 |
+
| 配置项 | 可选值 |
|
| 217 |
+
| :-- | :-- |
|
| 218 |
+
| `features.image_format` | `grok_url`, `local_url`, `grok_md`, `local_md`, `base64` |
|
| 219 |
+
| `features.imagine_public_image_proxy` | `true`, `false` |
|
| 220 |
+
| `features.video_format` | `grok_url`, `local_url`, `grok_html`, `local_html` |
|
| 221 |
+
|
| 222 |
+
<br>
|
| 223 |
+
|
| 224 |
+
## 模型支持
|
| 225 |
+
> 可通过 `GET /v1/models` 获取当前支持模型列表。
|
| 226 |
+
|
| 227 |
+
### Chat
|
| 228 |
+
|
| 229 |
+
| 模型名 | mode | tier |
|
| 230 |
+
| :-- | :-- | :-- |
|
| 231 |
+
| `grok-4.20-0309-non-reasoning` | `fast` | `basic` |
|
| 232 |
+
| `grok-4.20-0309` | `auto` | `super` |
|
| 233 |
+
| `grok-4.20-0309-reasoning` | `expert` | `super` |
|
| 234 |
+
| `grok-4.20-0309-non-reasoning-super` | `fast` | `super` |
|
| 235 |
+
| `grok-4.20-0309-super` | `auto` | `super` |
|
| 236 |
+
| `grok-4.20-0309-reasoning-super` | `expert` | `super` |
|
| 237 |
+
| `grok-4.20-0309-non-reasoning-heavy` | `fast` | `heavy` |
|
| 238 |
+
| `grok-4.20-0309-heavy` | `auto` | `heavy` |
|
| 239 |
+
| `grok-4.20-0309-reasoning-heavy` | `expert` | `heavy` |
|
| 240 |
+
| `grok-4.20-multi-agent-0309` | `heavy` | `heavy` |
|
| 241 |
+
| `grok-4.20-fast` | `fast` | `basic`,优先使用高等级账号池 |
|
| 242 |
+
| `grok-4.20-auto` | `auto` | `super`,优先使用高等级账号池 |
|
| 243 |
+
| `grok-4.20-expert` | `expert` | `super`,优先使用高等级账号池 |
|
| 244 |
+
| `grok-4.20-heavy` | `heavy` | `heavy` |
|
| 245 |
+
| `grok-4.3-beta` | `grok-420-computer-use-sa` | `super` |
|
| 246 |
+
|
| 247 |
+
### Image
|
| 248 |
+
|
| 249 |
+
| 模型名 | mode | tier |
|
| 250 |
+
| :-- | :-- | :-- |
|
| 251 |
+
| `grok-imagine-image-lite` | `fast` | `basic` |
|
| 252 |
+
| `grok-imagine-image` | `auto` | `super` |
|
| 253 |
+
| `grok-imagine-image-pro` | `auto` | `super` |
|
| 254 |
+
|
| 255 |
+
### Image Edit
|
| 256 |
+
|
| 257 |
+
| 模型名 | mode | tier |
|
| 258 |
+
| :-- | :-- | :-- |
|
| 259 |
+
| `grok-imagine-image-edit` | `auto` | `super` |
|
| 260 |
+
|
| 261 |
+
### Video
|
| 262 |
+
|
| 263 |
+
| 模型名 | mode | tier |
|
| 264 |
+
| :-- | :-- | :-- |
|
| 265 |
+
| `grok-imagine-video` | `auto` | `super` |
|
| 266 |
+
|
| 267 |
+
<br>
|
| 268 |
+
|
| 269 |
+
## API 一览
|
| 270 |
+
|
| 271 |
+
| 接口 | 是否鉴权 | 说明 |
|
| 272 |
+
| :-- | :-- | :-- |
|
| 273 |
+
| `GET /v1/models` | 是 | 列出当前启用模型 |
|
| 274 |
+
| `GET /v1/models/{model_id}` | 是 | 获取单个模型信息 |
|
| 275 |
+
| `POST /v1/chat/completions` | 是 | 对话 / 图像 / 视频统一入口 |
|
| 276 |
+
| `POST /v1/responses` | 是 | OpenAI Responses API 兼容子集 |
|
| 277 |
+
| `POST /v1/messages` | 是 | Anthropic Messages API 兼容接口 |
|
| 278 |
+
| `POST /v1/images/generations` | 是 | 独立图像生成接口 |
|
| 279 |
+
| `POST /v1/images/edits` | 是 | 独立图像编辑接口 |
|
| 280 |
+
| `POST /v1/videos` | 是 | 异步视频任务创建 |
|
| 281 |
+
| `GET /v1/videos/{video_id}` | 是 | 查询视频任务 |
|
| 282 |
+
| `GET /v1/videos/{video_id}/content` | 是 | 获取最终视频文件 |
|
| 283 |
+
| `GET /v1/files/video?id=...` | 否 | 获取本地缓存视频 |
|
| 284 |
+
| `GET /v1/files/image?id=...` | 否 | 获取本地缓存图片 |
|
| 285 |
+
|
| 286 |
+
<br>
|
| 287 |
+
|
| 288 |
+
## 接口示例
|
| 289 |
+
|
| 290 |
+
> 以下示例默认使用 `http://localhost:8000` 地址。
|
| 291 |
+
|
| 292 |
+
<details>
|
| 293 |
+
<summary><code>GET /v1/models</code></summary>
|
| 294 |
+
<br>
|
| 295 |
+
|
| 296 |
+
```bash
|
| 297 |
+
curl http://localhost:8000/v1/models \
|
| 298 |
+
-H "Authorization: Bearer $GROK2API_API_KEY"
|
| 299 |
+
```
|
| 300 |
+
|
| 301 |
+
<details>
|
| 302 |
+
<summary>字段说明</summary>
|
| 303 |
+
<br>
|
| 304 |
+
|
| 305 |
+
| 字段 | 位置 | 说明 |
|
| 306 |
+
| :-- | :-- | :-- |
|
| 307 |
+
| `Authorization` | Header | 当 `app.api_key` 非空时必填,格式为 `Bearer <api_key>` |
|
| 308 |
+
|
| 309 |
+
<br>
|
| 310 |
+
</details>
|
| 311 |
+
|
| 312 |
+
<br>
|
| 313 |
+
</details>
|
| 314 |
+
|
| 315 |
+
<details>
|
| 316 |
+
<summary><code>POST /v1/chat/completions</code></summary>
|
| 317 |
+
<br>
|
| 318 |
+
|
| 319 |
+
对话:
|
| 320 |
+
|
| 321 |
+
```bash
|
| 322 |
+
curl http://localhost:8000/v1/chat/completions \
|
| 323 |
+
-H "Content-Type: application/json" \
|
| 324 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 325 |
+
-d '{
|
| 326 |
+
"model": "grok-4.20-auto",
|
| 327 |
+
"stream": true,
|
| 328 |
+
"reasoning_effort": "high",
|
| 329 |
+
"messages": [
|
| 330 |
+
{"role":"user","content":"你好"}
|
| 331 |
+
]
|
| 332 |
+
}'
|
| 333 |
+
```
|
| 334 |
+
|
| 335 |
+
图像:
|
| 336 |
+
|
| 337 |
+
```bash
|
| 338 |
+
curl http://localhost:8000/v1/chat/completions \
|
| 339 |
+
-H "Content-Type: application/json" \
|
| 340 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 341 |
+
-d '{
|
| 342 |
+
"model": "grok-imagine-image",
|
| 343 |
+
"stream": true,
|
| 344 |
+
"messages": [
|
| 345 |
+
{"role":"user","content":"一只在太空漂浮的猫"}
|
| 346 |
+
],
|
| 347 |
+
"image_config": {
|
| 348 |
+
"n": 2,
|
| 349 |
+
"size": "1024x1024",
|
| 350 |
+
"response_format": "url"
|
| 351 |
+
}
|
| 352 |
+
}'
|
| 353 |
+
```
|
| 354 |
+
|
| 355 |
+
视频:
|
| 356 |
+
|
| 357 |
+
```bash
|
| 358 |
+
curl http://localhost:8000/v1/chat/completions \
|
| 359 |
+
-H "Content-Type: application/json" \
|
| 360 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 361 |
+
-d '{
|
| 362 |
+
"model": "grok-imagine-video",
|
| 363 |
+
"stream": true,
|
| 364 |
+
"messages": [
|
| 365 |
+
{"role":"user","content":"霓虹雨夜街头,电影感慢镜头追拍"}
|
| 366 |
+
],
|
| 367 |
+
"video_config": {
|
| 368 |
+
"seconds": 10,
|
| 369 |
+
"size": "1792x1024",
|
| 370 |
+
"resolution_name": "720p",
|
| 371 |
+
"preset": "normal"
|
| 372 |
+
}
|
| 373 |
+
}'
|
| 374 |
+
```
|
| 375 |
+
|
| 376 |
+
<details>
|
| 377 |
+
<summary>字段说明</summary>
|
| 378 |
+
<br>
|
| 379 |
+
|
| 380 |
+
| 字段 | 说明 |
|
| 381 |
+
| :-- | :-- |
|
| 382 |
+
| `messages` | 支持文本与多模态内容块 |
|
| 383 |
+
| `stream` | 是否流式输出;不传时使用 `features.stream` 默认值 |
|
| 384 |
+
| `reasoning_effort` | `none`, `minimal`, `low`, `medium`, `high`, `xhigh`;`none` 会关闭思考输出 |
|
| 385 |
+
| `temperature` / `top_p` | 采样参数,默认 `0.8` / `0.95` |
|
| 386 |
+
| `tools` | OpenAI function tools 结构 |
|
| 387 |
+
| `tool_choice` | `auto`, `required` 或指定函数工具 |
|
| 388 |
+
| `image_config` | 图像模型参数 |
|
| 389 |
+
| \|_ `n` | `lite` 为 `1-4`,其他图���模型为 `1-10`,编辑模型为 `1-2` |
|
| 390 |
+
| \|_ `size` | `1280x720`, `720x1280`, `1792x1024`, `1024x1792`, `1024x1024` |
|
| 391 |
+
| \|_ `response_format` | `url`, `b64_json` |
|
| 392 |
+
| `video_config` | 视频模型参数 |
|
| 393 |
+
| \|_ `seconds` | `6`, `10`, `12`, `16`, `20` |
|
| 394 |
+
| \|_ `size` | `720x1280`, `1280x720`, `1024x1024`, `1024x1792`, `1792x1024` |
|
| 395 |
+
| \|_ `resolution_name` | `480p`, `720p` |
|
| 396 |
+
| \|_ `preset` | `fun`, `normal`, `spicy`, `custom` |
|
| 397 |
+
|
| 398 |
+
<br>
|
| 399 |
+
</details>
|
| 400 |
+
|
| 401 |
+
<br>
|
| 402 |
+
</details>
|
| 403 |
+
|
| 404 |
+
<details>
|
| 405 |
+
<summary><code>POST /v1/responses</code></summary>
|
| 406 |
+
<br>
|
| 407 |
+
|
| 408 |
+
```bash
|
| 409 |
+
curl http://localhost:8000/v1/responses \
|
| 410 |
+
-H "Content-Type: application/json" \
|
| 411 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 412 |
+
-d '{
|
| 413 |
+
"model": "grok-4.20-auto",
|
| 414 |
+
"input": "解释一下量子隧穿",
|
| 415 |
+
"instructions": "用简洁的中文回答",
|
| 416 |
+
"stream": true,
|
| 417 |
+
"reasoning": {
|
| 418 |
+
"effort": "high"
|
| 419 |
+
}
|
| 420 |
+
}'
|
| 421 |
+
```
|
| 422 |
+
|
| 423 |
+
<details>
|
| 424 |
+
<summary>字段说明</summary>
|
| 425 |
+
<br>
|
| 426 |
+
|
| 427 |
+
| 字段 | 说明 |
|
| 428 |
+
| :-- | :-- |
|
| 429 |
+
| `model` | 模型 ID,需为已启用模型 |
|
| 430 |
+
| `input` | 用户输入;支持字符串或 Responses API 风格的消息数组 |
|
| 431 |
+
| `instructions` | 可选系统指令,会作为 system 消息注入 |
|
| 432 |
+
| `stream` | 是否流式输出;不传时使用 `features.stream` 默认值 |
|
| 433 |
+
| `reasoning` | 可选思考配置 |
|
| 434 |
+
| \|_ `effort` | `none` 会关闭思考输出;其他值会开启思考输出 |
|
| 435 |
+
| `temperature` / `top_p` | 采样参数,默认 `0.8` / `0.95` |
|
| 436 |
+
| `tools` / `tool_choice` | 支持函数工具;Responses API 的扁平工具格式会自动转换 |
|
| 437 |
+
|
| 438 |
+
<br>
|
| 439 |
+
</details>
|
| 440 |
+
|
| 441 |
+
<br>
|
| 442 |
+
</details>
|
| 443 |
+
|
| 444 |
+
<details>
|
| 445 |
+
<summary><code>POST /v1/messages</code></summary>
|
| 446 |
+
<br>
|
| 447 |
+
|
| 448 |
+
```bash
|
| 449 |
+
curl http://localhost:8000/v1/messages \
|
| 450 |
+
-H "Content-Type: application/json" \
|
| 451 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 452 |
+
-d '{
|
| 453 |
+
"model": "grok-4.20-auto",
|
| 454 |
+
"stream": true,
|
| 455 |
+
"thinking": {
|
| 456 |
+
"type": "enabled",
|
| 457 |
+
"budget_tokens": 1024
|
| 458 |
+
},
|
| 459 |
+
"messages": [
|
| 460 |
+
{
|
| 461 |
+
"role": "user",
|
| 462 |
+
"content": "用三句话解释量子隧穿"
|
| 463 |
+
}
|
| 464 |
+
]
|
| 465 |
+
}'
|
| 466 |
+
```
|
| 467 |
+
|
| 468 |
+
<details>
|
| 469 |
+
<summary>字段说明</summary>
|
| 470 |
+
<br>
|
| 471 |
+
|
| 472 |
+
| 字段 | 说明 |
|
| 473 |
+
| :-- | :-- |
|
| 474 |
+
| `model` | 模型 ID,需为已启用模型 |
|
| 475 |
+
| `messages` | Anthropic Messages 格式消息,支持文本、图片、文档和工具结果块 |
|
| 476 |
+
| `system` | 可选系统提示词,支持字符串或文本块数组 |
|
| 477 |
+
| `stream` | 是否流式输出;不传时使用 `features.stream` 默认值 |
|
| 478 |
+
| `thinking` | 可选思考配置 |
|
| 479 |
+
| \|_ `type` | `disabled` 会关闭思考输出;其他配置会开启思考输出 |
|
| 480 |
+
| `max_tokens` | 接收但当前会忽略,Grok 上游不暴露该参数 |
|
| 481 |
+
| `tools` / `tool_choice` | 支持 Anthropic 工具格式,会转换为内部 function tools |
|
| 482 |
+
|
| 483 |
+
<br>
|
| 484 |
+
</details>
|
| 485 |
+
|
| 486 |
+
<br>
|
| 487 |
+
</details>
|
| 488 |
+
|
| 489 |
+
<details>
|
| 490 |
+
<summary><code>POST /v1/images/generations</code></summary>
|
| 491 |
+
<br>
|
| 492 |
+
|
| 493 |
+
```bash
|
| 494 |
+
curl http://localhost:8000/v1/images/generations \
|
| 495 |
+
-H "Content-Type: application/json" \
|
| 496 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 497 |
+
-d '{
|
| 498 |
+
"model": "grok-imagine-image",
|
| 499 |
+
"prompt": "一只在太空漂浮的猫",
|
| 500 |
+
"n": 1,
|
| 501 |
+
"size": "1792x1024",
|
| 502 |
+
"response_format": "url"
|
| 503 |
+
}'
|
| 504 |
+
```
|
| 505 |
+
|
| 506 |
+
<details>
|
| 507 |
+
<summary>字段说明</summary>
|
| 508 |
+
<br>
|
| 509 |
+
|
| 510 |
+
| 字段 | 说明 |
|
| 511 |
+
| :-- | :-- |
|
| 512 |
+
| `model` | 图像模型:`grok-imagine-image-lite`, `grok-imagine-image`, `grok-imagine-image-pro` |
|
| 513 |
+
| `prompt` | 图片生成提示词 |
|
| 514 |
+
| `n` | 生成数量;`lite` 为 `1-4`,其他图像模型为 `1-10` |
|
| 515 |
+
| `size` | 支持 `1280x720`, `720x1280`, `1792x1024`, `1024x1792`, `1024x1024` |
|
| 516 |
+
| `response_format` | `url` 或 `b64_json` |
|
| 517 |
+
|
| 518 |
+
<br>
|
| 519 |
+
</details>
|
| 520 |
+
|
| 521 |
+
<br>
|
| 522 |
+
</details>
|
| 523 |
+
|
| 524 |
+
<details>
|
| 525 |
+
<summary><code>POST /v1/images/edits</code></summary>
|
| 526 |
+
<br>
|
| 527 |
+
|
| 528 |
+
```bash
|
| 529 |
+
curl http://localhost:8000/v1/images/edits \
|
| 530 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 531 |
+
-F "model=grok-imagine-image-edit" \
|
| 532 |
+
-F "prompt=把这张图变清晰一些" \
|
| 533 |
+
-F "image[]=@/path/to/image.png" \
|
| 534 |
+
-F "n=1" \
|
| 535 |
+
-F "size=1024x1024" \
|
| 536 |
+
-F "response_format=url"
|
| 537 |
+
```
|
| 538 |
+
|
| 539 |
+
<details>
|
| 540 |
+
<summary>字段说明</summary>
|
| 541 |
+
<br>
|
| 542 |
+
|
| 543 |
+
| 字段 | 说明 |
|
| 544 |
+
| :-- | :-- |
|
| 545 |
+
| `model` | 图像编辑模型,目前为 `grok-imagine-image-edit` |
|
| 546 |
+
| `prompt` | 编辑指令 |
|
| 547 |
+
| `image[]` | 参考图片,multipart 文件字段;最多使用 5 张 |
|
| 548 |
+
| `n` | 生成数量,范围 `1-2` |
|
| 549 |
+
| `size` | 当前仅支持 `1024x1024` |
|
| 550 |
+
| `response_format` | `url` 或 `b64_json` |
|
| 551 |
+
| `mask` | 暂不支持;传入会返回校验错误 |
|
| 552 |
+
|
| 553 |
+
<br>
|
| 554 |
+
</details>
|
| 555 |
+
|
| 556 |
+
<br>
|
| 557 |
+
</details>
|
| 558 |
+
|
| 559 |
+
<details>
|
| 560 |
+
<summary><code>POST /v1/videos</code></summary>
|
| 561 |
+
<br>
|
| 562 |
+
|
| 563 |
+
```bash
|
| 564 |
+
curl http://localhost:8000/v1/videos \
|
| 565 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 566 |
+
-F "model=grok-imagine-video" \
|
| 567 |
+
-F "prompt=霓虹雨夜街头,电影感慢镜头追拍" \
|
| 568 |
+
-F "seconds=10" \
|
| 569 |
+
-F "size=1792x1024" \
|
| 570 |
+
-F "resolution_name=720p" \
|
| 571 |
+
-F "preset=normal" \
|
| 572 |
+
-F "input_reference[]=@/path/to/reference.png"
|
| 573 |
+
```
|
| 574 |
+
|
| 575 |
+
```bash
|
| 576 |
+
curl http://localhost:8000/v1/videos/<video_id> \
|
| 577 |
+
-H "Authorization: Bearer $GROK2API_API_KEY"
|
| 578 |
+
|
| 579 |
+
curl -L http://localhost:8000/v1/videos/<video_id>/content \
|
| 580 |
+
-H "Authorization: Bearer $GROK2API_API_KEY" \
|
| 581 |
+
-o result.mp4
|
| 582 |
+
```
|
| 583 |
+
|
| 584 |
+
<details>
|
| 585 |
+
<summary>字段说明</summary>
|
| 586 |
+
<br>
|
| 587 |
+
|
| 588 |
+
| 字段 | 说明 |
|
| 589 |
+
| :-- | :-- |
|
| 590 |
+
| `model` | 视频模型,目前为 `grok-imagine-video` |
|
| 591 |
+
| `prompt` | 视频生成提示词 |
|
| 592 |
+
| `seconds` | 视频长度:`6`, `10`, `12`, `16`, `20` |
|
| 593 |
+
| `size` | 支持 `720x1280`, `1280x720`, `1024x1024`, `1024x1792`, `1792x1024` |
|
| 594 |
+
| `resolution_name` | `480p` 或 `720p` |
|
| 595 |
+
| `preset` | `fun`, `normal`, `spicy`, `custom` |
|
| 596 |
+
| `input_reference[]` | 可选图生视频参考图,multipart 文件字段;最多使用前 7 张 |
|
| 597 |
+
| `video_id` | `POST /v1/videos` 返回的视频任务 ID,用于查询任务或下载成片 |
|
| 598 |
+
|
| 599 |
+
<br>
|
| 600 |
+
</details>
|
| 601 |
+
|
| 602 |
+
<br>
|
| 603 |
+
</details>
|
| 604 |
+
|
| 605 |
+
<br>
|
| 606 |
+
|
| 607 |
+
## Star History
|
| 608 |
+
|
| 609 |
+
[](https://star-history.com/#Chenyme/grok2api&Timeline)
|
app/control/account/quota_defaults.py
CHANGED
|
@@ -3,12 +3,12 @@
|
|
| 3 |
Canonical quota totals per pool type (from upstream rate-limits API):
|
| 4 |
|
| 5 |
auto fast expert heavy grok_4_3
|
| 6 |
-
basic
|
| 7 |
super 50 140 50 — 50 window: 7200 s
|
| 8 |
heavy 150 400 150 20 150 window: 7200 s
|
| 9 |
|
| 10 |
-
Pool inference uses ``auto.total`` as the primary signal
|
| 11 |
-
|
| 12 |
"""
|
| 13 |
|
| 14 |
from typing import TYPE_CHECKING
|
|
@@ -40,10 +40,13 @@ def _w(remaining: int, total: int, window_seconds: int) -> QuotaWindow:
|
|
| 40 |
# Per-pool default quota sets
|
| 41 |
# ---------------------------------------------------------------------------
|
| 42 |
|
|
|
|
|
|
|
|
|
|
| 43 |
BASIC_QUOTA_DEFAULTS = AccountQuotaSet(
|
| 44 |
-
auto=_w(
|
| 45 |
-
fast=_w(
|
| 46 |
-
expert=_w(
|
| 47 |
)
|
| 48 |
|
| 49 |
SUPER_QUOTA_DEFAULTS = AccountQuotaSet(
|
|
@@ -69,7 +72,7 @@ _POOL_DEFAULTS: dict[str, AccountQuotaSet] = {
|
|
| 69 |
}
|
| 70 |
|
| 71 |
_SUPPORTED_MODE_IDS_BY_POOL: dict[str, frozenset[int]] = {
|
| 72 |
-
"basic": frozenset((
|
| 73 |
"super": frozenset((0, 1, 2, 4)),
|
| 74 |
"heavy": frozenset((0, 1, 2, 3, 4)),
|
| 75 |
}
|
|
@@ -124,6 +127,38 @@ def default_quota_window(pool: str, mode_id: int) -> QuotaWindow | None:
|
|
| 124 |
return default_quota_set(pool).get(mode_id)
|
| 125 |
|
| 126 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 127 |
def infer_pool(windows: dict[int, QuotaWindow]) -> str:
|
| 128 |
"""Infer pool type from live quota windows returned by the rate-limits API.
|
| 129 |
|
|
@@ -143,6 +178,8 @@ __all__ = [
|
|
| 143 |
"default_quota_set",
|
| 144 |
"default_quota_window",
|
| 145 |
"infer_pool",
|
|
|
|
|
|
|
| 146 |
"supported_mode_ids",
|
| 147 |
"supports_mode",
|
| 148 |
]
|
|
|
|
| 3 |
Canonical quota totals per pool type (from upstream rate-limits API):
|
| 4 |
|
| 5 |
auto fast expert heavy grok_4_3
|
| 6 |
+
basic — 30 — — — window: 86400 s
|
| 7 |
super 50 140 50 — 50 window: 7200 s
|
| 8 |
heavy 150 400 150 20 150 window: 7200 s
|
| 9 |
|
| 10 |
+
Pool inference uses ``auto.total`` as the primary signal for super/heavy
|
| 11 |
+
accounts; basic accounts no longer expose auto/expert windows locally.
|
| 12 |
"""
|
| 13 |
|
| 14 |
from typing import TYPE_CHECKING
|
|
|
|
| 40 |
# Per-pool default quota sets
|
| 41 |
# ---------------------------------------------------------------------------
|
| 42 |
|
| 43 |
+
BASIC_FAST_LIMIT = 30
|
| 44 |
+
BASIC_FAST_WINDOW_SECONDS = 86_400
|
| 45 |
+
|
| 46 |
BASIC_QUOTA_DEFAULTS = AccountQuotaSet(
|
| 47 |
+
auto=_w(0, 0, 0), # unsupported on basic accounts
|
| 48 |
+
fast=_w(BASIC_FAST_LIMIT, BASIC_FAST_LIMIT, BASIC_FAST_WINDOW_SECONDS),
|
| 49 |
+
expert=_w(0, 0, 0), # unsupported on basic accounts
|
| 50 |
)
|
| 51 |
|
| 52 |
SUPER_QUOTA_DEFAULTS = AccountQuotaSet(
|
|
|
|
| 72 |
}
|
| 73 |
|
| 74 |
_SUPPORTED_MODE_IDS_BY_POOL: dict[str, frozenset[int]] = {
|
| 75 |
+
"basic": frozenset((1,)),
|
| 76 |
"super": frozenset((0, 1, 2, 4)),
|
| 77 |
"heavy": frozenset((0, 1, 2, 3, 4)),
|
| 78 |
}
|
|
|
|
| 127 |
return default_quota_set(pool).get(mode_id)
|
| 128 |
|
| 129 |
|
| 130 |
+
def normalize_quota_window(
|
| 131 |
+
pool: str, mode_id: int, window: QuotaWindow | None
|
| 132 |
+
) -> QuotaWindow | None:
|
| 133 |
+
"""Apply product-level quota policy for one pool/mode window."""
|
| 134 |
+
if window is None or not supports_mode(pool, mode_id):
|
| 135 |
+
return None
|
| 136 |
+
if pool == "basic" and mode_id == 1:
|
| 137 |
+
return QuotaWindow(
|
| 138 |
+
remaining=max(0, min(int(window.remaining), BASIC_FAST_LIMIT)),
|
| 139 |
+
total=BASIC_FAST_LIMIT,
|
| 140 |
+
window_seconds=BASIC_FAST_WINDOW_SECONDS,
|
| 141 |
+
reset_at=window.reset_at,
|
| 142 |
+
synced_at=window.synced_at,
|
| 143 |
+
source=window.source,
|
| 144 |
+
)
|
| 145 |
+
return window
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def normalize_quota_set(pool: str, quota_set: AccountQuotaSet) -> AccountQuotaSet:
|
| 149 |
+
"""Return a quota set normalized to the supported modes for *pool*."""
|
| 150 |
+
defaults = default_quota_set(pool)
|
| 151 |
+
|
| 152 |
+
auto = normalize_quota_window(pool, 0, quota_set.auto) or defaults.auto
|
| 153 |
+
fast = normalize_quota_window(pool, 1, quota_set.fast) or defaults.fast
|
| 154 |
+
expert = normalize_quota_window(pool, 2, quota_set.expert) or defaults.expert
|
| 155 |
+
|
| 156 |
+
qs = AccountQuotaSet(auto=auto, fast=fast, expert=expert)
|
| 157 |
+
qs.heavy = normalize_quota_window(pool, 3, quota_set.heavy)
|
| 158 |
+
qs.grok_4_3 = normalize_quota_window(pool, 4, quota_set.grok_4_3)
|
| 159 |
+
return qs
|
| 160 |
+
|
| 161 |
+
|
| 162 |
def infer_pool(windows: dict[int, QuotaWindow]) -> str:
|
| 163 |
"""Infer pool type from live quota windows returned by the rate-limits API.
|
| 164 |
|
|
|
|
| 178 |
"default_quota_set",
|
| 179 |
"default_quota_window",
|
| 180 |
"infer_pool",
|
| 181 |
+
"normalize_quota_set",
|
| 182 |
+
"normalize_quota_window",
|
| 183 |
"supported_mode_ids",
|
| 184 |
"supports_mode",
|
| 185 |
]
|
app/control/account/refresh.py
CHANGED
|
@@ -15,6 +15,7 @@ from .models import AccountRecord, QuotaWindow
|
|
| 15 |
from .quota_defaults import (
|
| 16 |
default_quota_window,
|
| 17 |
infer_pool,
|
|
|
|
| 18 |
supported_mode_ids,
|
| 19 |
supports_mode,
|
| 20 |
)
|
|
@@ -78,7 +79,7 @@ class AccountRefreshService:
|
|
| 78 |
"""Fetch quota windows for every mode supported by *pool*.
|
| 79 |
|
| 80 |
Examples:
|
| 81 |
-
- basic ->
|
| 82 |
- super -> auto / fast / expert / grok_4_3
|
| 83 |
- heavy -> auto / fast / expert / heavy / grok_4_3
|
| 84 |
"""
|
|
@@ -258,7 +259,10 @@ class AccountRefreshService:
|
|
| 258 |
for mode in ALL_MODES_FULL:
|
| 259 |
mode_id = int(mode)
|
| 260 |
if mode_id in windows:
|
| 261 |
-
|
|
|
|
|
|
|
|
|
|
| 262 |
refreshed = True
|
| 263 |
elif apply_fallback:
|
| 264 |
existing = qs.get(mode_id)
|
|
@@ -448,7 +452,16 @@ class AccountRefreshService:
|
|
| 448 |
|
| 449 |
quota_patch: dict[str, dict] = {}
|
| 450 |
if window is not None:
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 452 |
else:
|
| 453 |
existing = qs.get(mode_id)
|
| 454 |
if existing is not None:
|
|
|
|
| 15 |
from .quota_defaults import (
|
| 16 |
default_quota_window,
|
| 17 |
infer_pool,
|
| 18 |
+
normalize_quota_window,
|
| 19 |
supported_mode_ids,
|
| 20 |
supports_mode,
|
| 21 |
)
|
|
|
|
| 79 |
"""Fetch quota windows for every mode supported by *pool*.
|
| 80 |
|
| 81 |
Examples:
|
| 82 |
+
- basic -> fast
|
| 83 |
- super -> auto / fast / expert / grok_4_3
|
| 84 |
- heavy -> auto / fast / expert / heavy / grok_4_3
|
| 85 |
"""
|
|
|
|
| 259 |
for mode in ALL_MODES_FULL:
|
| 260 |
mode_id = int(mode)
|
| 261 |
if mode_id in windows:
|
| 262 |
+
window = normalize_quota_window(record.pool, mode_id, windows[mode_id])
|
| 263 |
+
if window is None:
|
| 264 |
+
continue
|
| 265 |
+
patches[_MODE_KEYS[mode_id]] = window.to_dict()
|
| 266 |
refreshed = True
|
| 267 |
elif apply_fallback:
|
| 268 |
existing = qs.get(mode_id)
|
|
|
|
| 452 |
|
| 453 |
quota_patch: dict[str, dict] = {}
|
| 454 |
if window is not None:
|
| 455 |
+
normalized = normalize_quota_window(record.pool, mode_id, window)
|
| 456 |
+
if normalized is None:
|
| 457 |
+
logger.debug(
|
| 458 |
+
"account single-mode quota patch skipped: token={}... pool={} mode_id={} reason=unsupported_mode",
|
| 459 |
+
record.token[:10],
|
| 460 |
+
record.pool,
|
| 461 |
+
mode_id,
|
| 462 |
+
)
|
| 463 |
+
return
|
| 464 |
+
quota_patch[mode_key] = normalized.to_dict()
|
| 465 |
else:
|
| 466 |
existing = qs.get(mode_id)
|
| 467 |
if existing is not None:
|
app/control/account/scheduler.py
CHANGED
|
@@ -3,7 +3,7 @@
|
|
| 3 |
Runs one independent loop per pool type (basic / super / heavy), each with
|
| 4 |
its own configurable interval read from:
|
| 5 |
|
| 6 |
-
account.refresh.basic_interval_sec (default
|
| 7 |
account.refresh.super_interval_sec (default 7200 — 2 h)
|
| 8 |
account.refresh.heavy_interval_sec (default 7200 — 2 h)
|
| 9 |
"""
|
|
@@ -16,7 +16,7 @@ from .refresh import AccountRefreshService
|
|
| 16 |
|
| 17 |
# Pool → (config key, built-in default seconds)
|
| 18 |
_POOL_CONFIG: dict[str, tuple[str, int]] = {
|
| 19 |
-
"basic": ("account.refresh.basic_interval_sec",
|
| 20 |
"super": ("account.refresh.super_interval_sec", 7_200),
|
| 21 |
"heavy": ("account.refresh.heavy_interval_sec", 7_200),
|
| 22 |
}
|
|
|
|
| 3 |
Runs one independent loop per pool type (basic / super / heavy), each with
|
| 4 |
its own configurable interval read from:
|
| 5 |
|
| 6 |
+
account.refresh.basic_interval_sec (default 86400 — 24 h)
|
| 7 |
account.refresh.super_interval_sec (default 7200 — 2 h)
|
| 8 |
account.refresh.heavy_interval_sec (default 7200 — 2 h)
|
| 9 |
"""
|
|
|
|
| 16 |
|
| 17 |
# Pool → (config key, built-in default seconds)
|
| 18 |
_POOL_CONFIG: dict[str, tuple[str, int]] = {
|
| 19 |
+
"basic": ("account.refresh.basic_interval_sec", 86_400),
|
| 20 |
"super": ("account.refresh.super_interval_sec", 7_200),
|
| 21 |
"heavy": ("account.refresh.heavy_interval_sec", 7_200),
|
| 22 |
}
|
app/control/model/registry.py
CHANGED
|
@@ -12,10 +12,10 @@ from .spec import ModelSpec
|
|
| 12 |
MODELS: tuple[ModelSpec, ...] = (
|
| 13 |
# === Chat ==============================================================
|
| 14 |
|
| 15 |
-
# Basic+
|
| 16 |
ModelSpec("grok-4.20-0309-non-reasoning", ModeId.FAST, Tier.BASIC, Capability.CHAT, True, "Grok 4.20 0309 Non-Reasoning"),
|
| 17 |
-
ModelSpec("grok-4.20-0309", ModeId.AUTO, Tier.
|
| 18 |
-
ModelSpec("grok-4.20-0309-reasoning", ModeId.EXPERT, Tier.
|
| 19 |
# Super+
|
| 20 |
ModelSpec("grok-4.20-0309-non-reasoning-super", ModeId.FAST, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309 Non-Reasoning Super"),
|
| 21 |
ModelSpec("grok-4.20-0309-super", ModeId.AUTO, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309 Super"),
|
|
@@ -28,8 +28,8 @@ MODELS: tuple[ModelSpec, ...] = (
|
|
| 28 |
|
| 29 |
# --- 硬优先级反向选池 (heavy → super → basic) ---
|
| 30 |
ModelSpec("grok-4.20-fast", ModeId.FAST, Tier.BASIC, Capability.CHAT, True, "Grok 4.20 Fast", prefer_best=True),
|
| 31 |
-
ModelSpec("grok-4.20-auto", ModeId.AUTO, Tier.
|
| 32 |
-
ModelSpec("grok-4.20-expert", ModeId.EXPERT, Tier.
|
| 33 |
ModelSpec("grok-4.20-heavy", ModeId.HEAVY, Tier.HEAVY, Capability.CHAT, True, "Grok 4.20 Heavy", prefer_best=True),
|
| 34 |
|
| 35 |
# === grok-4.3 (grok-420-computer-use-sa) ==================================
|
|
@@ -38,7 +38,7 @@ MODELS: tuple[ModelSpec, ...] = (
|
|
| 38 |
|
| 39 |
# === Image ==============================================================
|
| 40 |
|
| 41 |
-
# Basic
|
| 42 |
ModelSpec("grok-imagine-image-lite", ModeId.FAST, Tier.BASIC, Capability.IMAGE, True, "Grok Imagine Image Lite"),
|
| 43 |
# Super+
|
| 44 |
ModelSpec("grok-imagine-image", ModeId.AUTO, Tier.SUPER, Capability.IMAGE, True, "Grok Imagine Image"),
|
|
|
|
| 12 |
MODELS: tuple[ModelSpec, ...] = (
|
| 13 |
# === Chat ==============================================================
|
| 14 |
|
| 15 |
+
# Basic fast; auto/expert require Super+
|
| 16 |
ModelSpec("grok-4.20-0309-non-reasoning", ModeId.FAST, Tier.BASIC, Capability.CHAT, True, "Grok 4.20 0309 Non-Reasoning"),
|
| 17 |
+
ModelSpec("grok-4.20-0309", ModeId.AUTO, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309"),
|
| 18 |
+
ModelSpec("grok-4.20-0309-reasoning", ModeId.EXPERT, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309 Reasoning"),
|
| 19 |
# Super+
|
| 20 |
ModelSpec("grok-4.20-0309-non-reasoning-super", ModeId.FAST, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309 Non-Reasoning Super"),
|
| 21 |
ModelSpec("grok-4.20-0309-super", ModeId.AUTO, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 0309 Super"),
|
|
|
|
| 28 |
|
| 29 |
# --- 硬优先级反向选池 (heavy → super → basic) ---
|
| 30 |
ModelSpec("grok-4.20-fast", ModeId.FAST, Tier.BASIC, Capability.CHAT, True, "Grok 4.20 Fast", prefer_best=True),
|
| 31 |
+
ModelSpec("grok-4.20-auto", ModeId.AUTO, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 Auto", prefer_best=True),
|
| 32 |
+
ModelSpec("grok-4.20-expert", ModeId.EXPERT, Tier.SUPER, Capability.CHAT, True, "Grok 4.20 Expert", prefer_best=True),
|
| 33 |
ModelSpec("grok-4.20-heavy", ModeId.HEAVY, Tier.HEAVY, Capability.CHAT, True, "Grok 4.20 Heavy", prefer_best=True),
|
| 34 |
|
| 35 |
# === grok-4.3 (grok-420-computer-use-sa) ==================================
|
|
|
|
| 38 |
|
| 39 |
# === Image ==============================================================
|
| 40 |
|
| 41 |
+
# Basic fast
|
| 42 |
ModelSpec("grok-imagine-image-lite", ModeId.FAST, Tier.BASIC, Capability.IMAGE, True, "Grok Imagine Image Lite"),
|
| 43 |
# Super+
|
| 44 |
ModelSpec("grok-imagine-image", ModeId.AUTO, Tier.SUPER, Capability.IMAGE, True, "Grok Imagine Image"),
|
app/control/model/spec.py
CHANGED
|
@@ -74,12 +74,15 @@ class ModelSpec:
|
|
| 74 |
HEAVY tier → heavy only
|
| 75 |
|
| 76 |
Reversed (prefer_best=True):
|
| 77 |
-
|
|
|
|
| 78 |
HEAVY tier → heavy only
|
| 79 |
"""
|
| 80 |
if self.prefer_best:
|
| 81 |
if self.tier == Tier.HEAVY:
|
| 82 |
return (2,) # heavy only
|
|
|
|
|
|
|
| 83 |
return (2, 1, 0) # heavy, super, basic
|
| 84 |
if self.tier == Tier.BASIC:
|
| 85 |
return (0, 1, 2) # basic, super, heavy
|
|
|
|
| 74 |
HEAVY tier → heavy only
|
| 75 |
|
| 76 |
Reversed (prefer_best=True):
|
| 77 |
+
BASIC tier → try heavy first, then super, then basic
|
| 78 |
+
SUPER tier → try heavy first, then super
|
| 79 |
HEAVY tier → heavy only
|
| 80 |
"""
|
| 81 |
if self.prefer_best:
|
| 82 |
if self.tier == Tier.HEAVY:
|
| 83 |
return (2,) # heavy only
|
| 84 |
+
if self.tier == Tier.SUPER:
|
| 85 |
+
return (2, 1) # heavy, super
|
| 86 |
return (2, 1, 0) # heavy, super, basic
|
| 87 |
if self.tier == Tier.BASIC:
|
| 88 |
return (0, 1, 2) # basic, super, heavy
|
app/dataplane/account/__init__.py
CHANGED
|
@@ -307,7 +307,7 @@ class AccountDirectory:
|
|
| 307 |
|
| 308 |
|
| 309 |
_POOL_INTERVAL_CONFIG: dict[str, tuple[str, int]] = {
|
| 310 |
-
"basic": ("account.refresh.basic_interval_sec",
|
| 311 |
"super": ("account.refresh.super_interval_sec", 7_200),
|
| 312 |
"heavy": ("account.refresh.heavy_interval_sec", 7_200),
|
| 313 |
}
|
|
|
|
| 307 |
|
| 308 |
|
| 309 |
_POOL_INTERVAL_CONFIG: dict[str, tuple[str, int]] = {
|
| 310 |
+
"basic": ("account.refresh.basic_interval_sec", 86_400),
|
| 311 |
"super": ("account.refresh.super_interval_sec", 7_200),
|
| 312 |
"heavy": ("account.refresh.heavy_interval_sec", 7_200),
|
| 313 |
}
|
app/dataplane/account/sync.py
CHANGED
|
@@ -8,6 +8,7 @@ Two modes:
|
|
| 8 |
from app.platform.logging.logger import logger
|
| 9 |
from app.platform.runtime.clock import ms_to_s
|
| 10 |
from app.control.account.models import AccountRecord
|
|
|
|
| 11 |
from app.control.account.repository import AccountRepository
|
| 12 |
from app.control.account.state_machine import derive_status
|
| 13 |
from ..shared.enums import POOL_STR_TO_ID, STATUS_STR_TO_ID, StatusId
|
|
@@ -16,7 +17,7 @@ from .table import AccountRuntimeTable, make_empty_table
|
|
| 16 |
|
| 17 |
def _record_to_slot_args(record: AccountRecord) -> dict:
|
| 18 |
"""Extract columnar values from a control-plane AccountRecord."""
|
| 19 |
-
qs = record.quota_set()
|
| 20 |
status_id = STATUS_STR_TO_ID.get(str(derive_status(record)), int(StatusId.ACTIVE))
|
| 21 |
pool_id = POOL_STR_TO_ID.get(record.pool, 0)
|
| 22 |
|
|
|
|
| 8 |
from app.platform.logging.logger import logger
|
| 9 |
from app.platform.runtime.clock import ms_to_s
|
| 10 |
from app.control.account.models import AccountRecord
|
| 11 |
+
from app.control.account.quota_defaults import normalize_quota_set
|
| 12 |
from app.control.account.repository import AccountRepository
|
| 13 |
from app.control.account.state_machine import derive_status
|
| 14 |
from ..shared.enums import POOL_STR_TO_ID, STATUS_STR_TO_ID, StatusId
|
|
|
|
| 17 |
|
| 18 |
def _record_to_slot_args(record: AccountRecord) -> dict:
|
| 19 |
"""Extract columnar values from a control-plane AccountRecord."""
|
| 20 |
+
qs = normalize_quota_set(record.pool, record.quota_set())
|
| 21 |
status_id = STATUS_STR_TO_ID.get(str(derive_status(record)), int(StatusId.ACTIVE))
|
| 22 |
pool_id = POOL_STR_TO_ID.get(record.pool, 0)
|
| 23 |
|
app/dataplane/reverse/protocol/xai_chat.py
CHANGED
|
@@ -6,6 +6,7 @@ from typing import Any
|
|
| 6 |
|
| 7 |
import orjson
|
| 8 |
|
|
|
|
| 9 |
from app.platform.logging.logger import logger
|
| 10 |
from app.platform.config.snapshot import get_config
|
| 11 |
from app.control.model.enums import ModeId
|
|
@@ -113,6 +114,46 @@ def classify_line(line: str | bytes) -> tuple[str, str]:
|
|
| 113 |
return "skip", ""
|
| 114 |
|
| 115 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
# ---------------------------------------------------------------------------
|
| 117 |
# FrameEvent — single output event from StreamAdapter.feed()
|
| 118 |
# ---------------------------------------------------------------------------
|
|
@@ -259,6 +300,7 @@ class StreamAdapter:
|
|
| 259 |
obj = orjson.loads(data)
|
| 260 |
except (orjson.JSONDecodeError, ValueError, TypeError):
|
| 261 |
return []
|
|
|
|
| 262 |
|
| 263 |
result = obj.get("result")
|
| 264 |
if not result:
|
|
|
|
| 6 |
|
| 7 |
import orjson
|
| 8 |
|
| 9 |
+
from app.platform.errors import UpstreamError
|
| 10 |
from app.platform.logging.logger import logger
|
| 11 |
from app.platform.config.snapshot import get_config
|
| 12 |
from app.control.model.enums import ModeId
|
|
|
|
| 114 |
return "skip", ""
|
| 115 |
|
| 116 |
|
| 117 |
+
def stream_error_from_payload(obj: dict[str, Any]) -> UpstreamError | None:
|
| 118 |
+
"""Convert upstream in-band stream error payloads to retryable errors."""
|
| 119 |
+
error = obj.get("error")
|
| 120 |
+
if not isinstance(error, dict):
|
| 121 |
+
return None
|
| 122 |
+
|
| 123 |
+
raw_message = error.get("message") or error.get("error") or "Upstream stream error"
|
| 124 |
+
message = str(raw_message)
|
| 125 |
+
code = error.get("code")
|
| 126 |
+
text = message.lower()
|
| 127 |
+
status = 429 if code == 8 or "too many requests" in text or "rate limit" in text else 502
|
| 128 |
+
|
| 129 |
+
try:
|
| 130 |
+
body = orjson.dumps(obj).decode()
|
| 131 |
+
except (TypeError, ValueError):
|
| 132 |
+
body = str(obj)
|
| 133 |
+
|
| 134 |
+
return UpstreamError(
|
| 135 |
+
f"Upstream stream error: {message}",
|
| 136 |
+
status=status,
|
| 137 |
+
body=body[:400],
|
| 138 |
+
)
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def raise_for_stream_error(data: str | bytes | dict[str, Any]) -> None:
|
| 142 |
+
"""Raise :class:`UpstreamError` for raw or decoded in-band stream errors."""
|
| 143 |
+
if isinstance(data, dict):
|
| 144 |
+
obj = data
|
| 145 |
+
else:
|
| 146 |
+
try:
|
| 147 |
+
obj = orjson.loads(data)
|
| 148 |
+
except (orjson.JSONDecodeError, ValueError, TypeError):
|
| 149 |
+
return
|
| 150 |
+
if not isinstance(obj, dict):
|
| 151 |
+
return
|
| 152 |
+
exc = stream_error_from_payload(obj)
|
| 153 |
+
if exc is not None:
|
| 154 |
+
raise exc
|
| 155 |
+
|
| 156 |
+
|
| 157 |
# ---------------------------------------------------------------------------
|
| 158 |
# FrameEvent — single output event from StreamAdapter.feed()
|
| 159 |
# ---------------------------------------------------------------------------
|
|
|
|
| 300 |
obj = orjson.loads(data)
|
| 301 |
except (orjson.JSONDecodeError, ValueError, TypeError):
|
| 302 |
return []
|
| 303 |
+
raise_for_stream_error(obj)
|
| 304 |
|
| 305 |
result = obj.get("result")
|
| 306 |
if not result:
|
app/dataplane/reverse/protocol/xai_image_edit.py
CHANGED
|
@@ -28,7 +28,6 @@ def build_image_edit_payload(
|
|
| 28 |
"enableImageStreaming": True,
|
| 29 |
"imageGenerationCount": IMAGE_EDIT_GENERATION_COUNT,
|
| 30 |
"forceConcise": False,
|
| 31 |
-
"toolOverrides": {"imageGen": True},
|
| 32 |
"enableSideBySide": True,
|
| 33 |
"sendFinalMetadata": True,
|
| 34 |
"isReasoning": False,
|
|
|
|
| 28 |
"enableImageStreaming": True,
|
| 29 |
"imageGenerationCount": IMAGE_EDIT_GENERATION_COUNT,
|
| 30 |
"forceConcise": False,
|
|
|
|
| 31 |
"enableSideBySide": True,
|
| 32 |
"sendFinalMetadata": True,
|
| 33 |
"isReasoning": False,
|
app/dataplane/reverse/protocol/xai_usage.py
CHANGED
|
@@ -25,9 +25,9 @@ _MODE_NAMES: dict[int, str] = {
|
|
| 25 |
|
| 26 |
# Default window durations used as fallback when API call fails.
|
| 27 |
_DEFAULT_WINDOW_SECS: dict[int, int] = {
|
| 28 |
-
0:
|
| 29 |
-
1:
|
| 30 |
-
2:
|
| 31 |
3: 7_200, # heavy — 2 h (heavy-pool only)
|
| 32 |
4: 7_200, # grok_4_3 — 2 h (super/heavy only)
|
| 33 |
}
|
|
@@ -43,7 +43,9 @@ def _build_payload(mode_name: str) -> bytes:
|
|
| 43 |
# ---------------------------------------------------------------------------
|
| 44 |
|
| 45 |
|
| 46 |
-
def parse_rate_limits(
|
|
|
|
|
|
|
| 47 |
"""Parse flat rate-limits response.
|
| 48 |
|
| 49 |
Expected format::
|
|
@@ -67,7 +69,7 @@ def parse_rate_limits(body: dict) -> dict | None:
|
|
| 67 |
return {
|
| 68 |
"remaining": int(remaining),
|
| 69 |
"total": int(total) if total is not None else int(remaining),
|
| 70 |
-
"window_seconds": int(window_secs) if window_secs else
|
| 71 |
}
|
| 72 |
|
| 73 |
|
|
@@ -144,7 +146,10 @@ async def _fetch_one(token: str, mode_id: int) -> object | None:
|
|
| 144 |
)
|
| 145 |
return None
|
| 146 |
|
| 147 |
-
data = parse_rate_limits(
|
|
|
|
|
|
|
|
|
|
| 148 |
if data is None:
|
| 149 |
logger.debug(
|
| 150 |
"rate-limits response missing quota fields: token={}... mode={} body={}",
|
|
|
|
| 25 |
|
| 26 |
# Default window durations used as fallback when API call fails.
|
| 27 |
_DEFAULT_WINDOW_SECS: dict[int, int] = {
|
| 28 |
+
0: 7_200, # auto — 2 h (super/heavy only)
|
| 29 |
+
1: 86_400, # fast — 24 h (basic; real value overrides for super/heavy)
|
| 30 |
+
2: 7_200, # expert — 2 h (super/heavy only)
|
| 31 |
3: 7_200, # heavy — 2 h (heavy-pool only)
|
| 32 |
4: 7_200, # grok_4_3 — 2 h (super/heavy only)
|
| 33 |
}
|
|
|
|
| 43 |
# ---------------------------------------------------------------------------
|
| 44 |
|
| 45 |
|
| 46 |
+
def parse_rate_limits(
|
| 47 |
+
body: dict, *, default_window_seconds: int = 72_000
|
| 48 |
+
) -> dict | None:
|
| 49 |
"""Parse flat rate-limits response.
|
| 50 |
|
| 51 |
Expected format::
|
|
|
|
| 69 |
return {
|
| 70 |
"remaining": int(remaining),
|
| 71 |
"total": int(total) if total is not None else int(remaining),
|
| 72 |
+
"window_seconds": int(window_secs) if window_secs else default_window_seconds,
|
| 73 |
}
|
| 74 |
|
| 75 |
|
|
|
|
| 146 |
)
|
| 147 |
return None
|
| 148 |
|
| 149 |
+
data = parse_rate_limits(
|
| 150 |
+
body,
|
| 151 |
+
default_window_seconds=_DEFAULT_WINDOW_SECS.get(mode_id, 72_000),
|
| 152 |
+
)
|
| 153 |
if data is None:
|
| 154 |
logger.debug(
|
| 155 |
"rate-limits response missing quota fields: token={}... mode={} body={}",
|
app/dataplane/reverse/transport/assets.py
CHANGED
|
@@ -7,8 +7,23 @@ give feedback, and return results to the caller.
|
|
| 7 |
import asyncio
|
| 8 |
from typing import Any, AsyncGenerator, Dict, Optional
|
| 9 |
|
| 10 |
-
from app.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
from app.platform.config.snapshot import get_config
|
|
|
|
|
|
|
| 12 |
|
| 13 |
# Global semaphores — limit concurrent transport calls across all callers.
|
| 14 |
# Lazily initialised so the event loop is guaranteed to be running on first use.
|
|
@@ -28,21 +43,6 @@ def _get_delete_sem() -> asyncio.Semaphore:
|
|
| 28 |
n = max(1, int(get_config("batch.asset_delete_concurrency", 50)))
|
| 29 |
_delete_sem = asyncio.Semaphore(n)
|
| 30 |
return _delete_sem
|
| 31 |
-
from app.platform.errors import UpstreamError
|
| 32 |
-
from app.control.proxy.models import ProxyFeedback, ProxyFeedbackKind, ProxyScope, RequestKind
|
| 33 |
-
from app.dataplane.reverse.transport._proxy_feedback import upstream_feedback
|
| 34 |
-
from app.dataplane.proxy import get_proxy_runtime
|
| 35 |
-
from app.dataplane.reverse.protocol.xai_assets import (
|
| 36 |
-
ASSETS_LIST_URL,
|
| 37 |
-
asset_delete_url,
|
| 38 |
-
infer_content_type,
|
| 39 |
-
resolve_download_url,
|
| 40 |
-
)
|
| 41 |
-
from app.dataplane.reverse.transport.http import (
|
| 42 |
-
delete_json,
|
| 43 |
-
get_bytes_stream,
|
| 44 |
-
get_json,
|
| 45 |
-
)
|
| 46 |
|
| 47 |
|
| 48 |
# ------------------------------------------------------------------
|
|
@@ -174,16 +174,24 @@ async def download_asset(
|
|
| 174 |
url, origin, referer = resolve_download_url(file_path)
|
| 175 |
content_type = infer_content_type(url)
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
extra: Dict[str, str] = {
|
|
|
|
| 178 |
"Cache-Control": "no-cache",
|
| 179 |
"Pragma": "no-cache",
|
| 180 |
"Priority": "u=0, i",
|
|
|
|
| 181 |
"Sec-Fetch-Mode": "navigate",
|
|
|
|
| 182 |
"Sec-Fetch-User": "?1",
|
| 183 |
"Upgrade-Insecure-Requests": "1",
|
| 184 |
}
|
| 185 |
-
if content_type:
|
| 186 |
-
extra["Content-Type"] = content_type
|
| 187 |
|
| 188 |
proxy = await get_proxy_runtime()
|
| 189 |
lease = await proxy.acquire(scope=ProxyScope.ASSET, kind=RequestKind.HTTP)
|
|
|
|
| 7 |
import asyncio
|
| 8 |
from typing import Any, AsyncGenerator, Dict, Optional
|
| 9 |
|
| 10 |
+
from app.control.proxy.models import ProxyFeedback, ProxyFeedbackKind, ProxyScope, RequestKind
|
| 11 |
+
from app.dataplane.proxy import get_proxy_runtime
|
| 12 |
+
from app.dataplane.reverse.protocol.xai_assets import (
|
| 13 |
+
ASSETS_LIST_URL,
|
| 14 |
+
asset_delete_url,
|
| 15 |
+
infer_content_type,
|
| 16 |
+
resolve_download_url,
|
| 17 |
+
)
|
| 18 |
+
from app.dataplane.reverse.transport._proxy_feedback import upstream_feedback
|
| 19 |
+
from app.dataplane.reverse.transport.http import (
|
| 20 |
+
delete_json,
|
| 21 |
+
get_bytes_stream,
|
| 22 |
+
get_json,
|
| 23 |
+
)
|
| 24 |
from app.platform.config.snapshot import get_config
|
| 25 |
+
from app.platform.errors import UpstreamError
|
| 26 |
+
from app.platform.logging.logger import logger
|
| 27 |
|
| 28 |
# Global semaphores — limit concurrent transport calls across all callers.
|
| 29 |
# Lazily initialised so the event loop is guaranteed to be running on first use.
|
|
|
|
| 43 |
n = max(1, int(get_config("batch.asset_delete_concurrency", 50)))
|
| 44 |
_delete_sem = asyncio.Semaphore(n)
|
| 45 |
return _delete_sem
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
|
| 47 |
|
| 48 |
# ------------------------------------------------------------------
|
|
|
|
| 174 |
url, origin, referer = resolve_download_url(file_path)
|
| 175 |
content_type = infer_content_type(url)
|
| 176 |
|
| 177 |
+
if content_type and content_type.startswith("video/"):
|
| 178 |
+
accept = "video/mp4,video/*,*/*;q=0.8"
|
| 179 |
+
elif content_type and content_type.startswith("image/"):
|
| 180 |
+
accept = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
|
| 181 |
+
else:
|
| 182 |
+
accept = "*/*"
|
| 183 |
+
|
| 184 |
extra: Dict[str, str] = {
|
| 185 |
+
"Accept": accept,
|
| 186 |
"Cache-Control": "no-cache",
|
| 187 |
"Pragma": "no-cache",
|
| 188 |
"Priority": "u=0, i",
|
| 189 |
+
"Sec-Fetch-Dest": "document",
|
| 190 |
"Sec-Fetch-Mode": "navigate",
|
| 191 |
+
"Sec-Fetch-Site": "none",
|
| 192 |
"Sec-Fetch-User": "?1",
|
| 193 |
"Upgrade-Insecure-Requests": "1",
|
| 194 |
}
|
|
|
|
|
|
|
| 195 |
|
| 196 |
proxy = await get_proxy_runtime()
|
| 197 |
lease = await proxy.acquire(scope=ProxyScope.ASSET, kind=RequestKind.HTTP)
|
app/dataplane/reverse/transport/http.py
CHANGED
|
@@ -248,6 +248,9 @@ async def get_bytes_stream(
|
|
| 248 |
)
|
| 249 |
if extra_headers:
|
| 250 |
headers.update(extra_headers)
|
|
|
|
|
|
|
|
|
|
| 251 |
kwargs = build_session_kwargs(lease=lease)
|
| 252 |
|
| 253 |
session = ResettableSession(**kwargs)
|
|
@@ -259,7 +262,6 @@ async def get_bytes_stream(
|
|
| 259 |
stream=True,
|
| 260 |
allow_redirects=True,
|
| 261 |
)
|
| 262 |
-
|
| 263 |
if response.status_code != 200:
|
| 264 |
try:
|
| 265 |
body = (response.content).decode("utf-8", "replace")[:400]
|
|
|
|
| 248 |
)
|
| 249 |
if extra_headers:
|
| 250 |
headers.update(extra_headers)
|
| 251 |
+
if headers.get("Sec-Fetch-Mode") == "navigate":
|
| 252 |
+
headers.pop("Content-Type", None)
|
| 253 |
+
headers.pop("Origin", None)
|
| 254 |
kwargs = build_session_kwargs(lease=lease)
|
| 255 |
|
| 256 |
session = ResettableSession(**kwargs)
|
|
|
|
| 262 |
stream=True,
|
| 263 |
allow_redirects=True,
|
| 264 |
)
|
|
|
|
| 265 |
if response.status_code != 200:
|
| 266 |
try:
|
| 267 |
body = (response.content).decode("utf-8", "replace")[:400]
|
app/platform/startup/migration.py
CHANGED
|
@@ -31,6 +31,7 @@ from loguru import logger
|
|
| 31 |
from app.platform.paths import data_path
|
| 32 |
|
| 33 |
if TYPE_CHECKING:
|
|
|
|
| 34 |
from app.control.account.repository import AccountRepository
|
| 35 |
from app.platform.config.backends.base import ConfigBackend
|
| 36 |
|
|
@@ -51,8 +52,10 @@ async def run_startup_migrations(
|
|
| 51 |
) -> None:
|
| 52 |
"""Run all first-boot migrations. Safe to call on every startup."""
|
| 53 |
await _migrate_config(config_backend)
|
|
|
|
| 54 |
await _migrate_accounts(account_repo)
|
| 55 |
await _backfill_grok_4_3_quota(account_repo)
|
|
|
|
| 56 |
|
| 57 |
|
| 58 |
# ---------------------------------------------------------------------------
|
|
@@ -91,6 +94,21 @@ async def _migrate_config(backend: "ConfigBackend") -> None:
|
|
| 91 |
logger.debug("config: {} backend is empty, no local overrides to migrate", backend_name)
|
| 92 |
|
| 93 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 94 |
# ---------------------------------------------------------------------------
|
| 95 |
# Account migration
|
| 96 |
# ---------------------------------------------------------------------------
|
|
@@ -123,7 +141,7 @@ async def _migrate_accounts(target_repo: "AccountRepository") -> None:
|
|
| 123 |
async def _copy_accounts(sqlite_path: Path, target: "AccountRepository") -> int:
|
| 124 |
"""Read all accounts from the local SQLite file and write to *target*."""
|
| 125 |
from app.control.account.backends.local import LocalAccountRepository
|
| 126 |
-
from app.control.account.commands import
|
| 127 |
|
| 128 |
source = LocalAccountRepository(sqlite_path)
|
| 129 |
await source.initialize()
|
|
@@ -230,6 +248,48 @@ async def _backfill_grok_4_3_quota(repo: "AccountRepository") -> None:
|
|
| 230 |
logger.info("account: backfilled quota_grok_4_3 for {} super/heavy accounts", total)
|
| 231 |
|
| 232 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 233 |
# ---------------------------------------------------------------------------
|
| 234 |
# Helpers
|
| 235 |
# ---------------------------------------------------------------------------
|
|
|
|
| 31 |
from app.platform.paths import data_path
|
| 32 |
|
| 33 |
if TYPE_CHECKING:
|
| 34 |
+
from app.control.account.commands import AccountPatch
|
| 35 |
from app.control.account.repository import AccountRepository
|
| 36 |
from app.platform.config.backends.base import ConfigBackend
|
| 37 |
|
|
|
|
| 52 |
) -> None:
|
| 53 |
"""Run all first-boot migrations. Safe to call on every startup."""
|
| 54 |
await _migrate_config(config_backend)
|
| 55 |
+
await _migrate_basic_refresh_interval(config_backend)
|
| 56 |
await _migrate_accounts(account_repo)
|
| 57 |
await _backfill_grok_4_3_quota(account_repo)
|
| 58 |
+
await _normalize_basic_fast_only_quota(account_repo)
|
| 59 |
|
| 60 |
|
| 61 |
# ---------------------------------------------------------------------------
|
|
|
|
| 94 |
logger.debug("config: {} backend is empty, no local overrides to migrate", backend_name)
|
| 95 |
|
| 96 |
|
| 97 |
+
async def _migrate_basic_refresh_interval(backend: "ConfigBackend") -> None:
|
| 98 |
+
data = await backend.load()
|
| 99 |
+
account = data.get("account", {})
|
| 100 |
+
refresh = account.get("refresh", {}) if isinstance(account, dict) else {}
|
| 101 |
+
value = refresh.get("basic_interval_sec") if isinstance(refresh, dict) else None
|
| 102 |
+
try:
|
| 103 |
+
old_default = int(value)
|
| 104 |
+
except (TypeError, ValueError):
|
| 105 |
+
return
|
| 106 |
+
if old_default != 36_000:
|
| 107 |
+
return
|
| 108 |
+
await backend.apply_patch({"account": {"refresh": {"basic_interval_sec": 86_400}}})
|
| 109 |
+
logger.info("config: updated basic refresh interval default from 36000s to 86400s")
|
| 110 |
+
|
| 111 |
+
|
| 112 |
# ---------------------------------------------------------------------------
|
| 113 |
# Account migration
|
| 114 |
# ---------------------------------------------------------------------------
|
|
|
|
| 141 |
async def _copy_accounts(sqlite_path: Path, target: "AccountRepository") -> int:
|
| 142 |
"""Read all accounts from the local SQLite file and write to *target*."""
|
| 143 |
from app.control.account.backends.local import LocalAccountRepository
|
| 144 |
+
from app.control.account.commands import AccountUpsert, ListAccountsQuery
|
| 145 |
|
| 146 |
source = LocalAccountRepository(sqlite_path)
|
| 147 |
await source.initialize()
|
|
|
|
| 248 |
logger.info("account: backfilled quota_grok_4_3 for {} super/heavy accounts", total)
|
| 249 |
|
| 250 |
|
| 251 |
+
async def _normalize_basic_fast_only_quota(repo: "AccountRepository") -> None:
|
| 252 |
+
from app.control.account.commands import AccountPatch, ListAccountsQuery
|
| 253 |
+
from app.control.account.quota_defaults import normalize_quota_set
|
| 254 |
+
|
| 255 |
+
patches: list[AccountPatch] = []
|
| 256 |
+
page = 1
|
| 257 |
+
while True:
|
| 258 |
+
result = await repo.list_accounts(
|
| 259 |
+
ListAccountsQuery(
|
| 260 |
+
page=page,
|
| 261 |
+
page_size=_BATCH,
|
| 262 |
+
pool="basic",
|
| 263 |
+
include_deleted=False,
|
| 264 |
+
)
|
| 265 |
+
)
|
| 266 |
+
for record in result.items:
|
| 267 |
+
normalized = normalize_quota_set("basic", record.quota_set())
|
| 268 |
+
if normalized.to_dict() == record.quota_set().to_dict():
|
| 269 |
+
continue
|
| 270 |
+
patches.append(
|
| 271 |
+
AccountPatch(
|
| 272 |
+
token=record.token,
|
| 273 |
+
quota_auto=normalized.auto.to_dict(),
|
| 274 |
+
quota_fast=normalized.fast.to_dict(),
|
| 275 |
+
quota_expert=normalized.expert.to_dict(),
|
| 276 |
+
)
|
| 277 |
+
)
|
| 278 |
+
if page >= result.total_pages:
|
| 279 |
+
break
|
| 280 |
+
page += 1
|
| 281 |
+
|
| 282 |
+
if not patches:
|
| 283 |
+
return
|
| 284 |
+
|
| 285 |
+
total = 0
|
| 286 |
+
for i in range(0, len(patches), _BATCH):
|
| 287 |
+
batch = patches[i : i + _BATCH]
|
| 288 |
+
res = await repo.patch_accounts(batch)
|
| 289 |
+
total += res.patched
|
| 290 |
+
logger.info("account: normalized {} basic accounts to fast-only quota", total)
|
| 291 |
+
|
| 292 |
+
|
| 293 |
# ---------------------------------------------------------------------------
|
| 294 |
# Helpers
|
| 295 |
# ---------------------------------------------------------------------------
|
app/products/openai/chat.py
CHANGED
|
@@ -4,6 +4,7 @@ import asyncio
|
|
| 4 |
import base64
|
| 5 |
import re
|
| 6 |
from typing import Any, AsyncGenerator
|
|
|
|
| 7 |
|
| 8 |
import orjson
|
| 9 |
|
|
@@ -213,6 +214,14 @@ def _save_image(raw: bytes, mime: str, image_id: str) -> str:
|
|
| 213 |
return save_local_image(raw, mime, image_id)
|
| 214 |
|
| 215 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 216 |
async def _resolve_image(token: str, url: str, image_id: str) -> str:
|
| 217 |
"""Return the image embed text for the response body based on image_format config.
|
| 218 |
|
|
@@ -226,10 +235,15 @@ async def _resolve_image(token: str, url: str, image_id: str) -> str:
|
|
| 226 |
cfg = get_config()
|
| 227 |
fmt = _normalize_image_format(cfg.get_str("features.image_format", "grok_url"))
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
# Formats that don't need downloading
|
| 230 |
-
if fmt == "grok_url":
|
| 231 |
return url
|
| 232 |
-
if fmt == "grok_md":
|
| 233 |
return f""
|
| 234 |
|
| 235 |
# Formats that require downloading
|
|
@@ -254,9 +268,9 @@ async def _resolve_image(token: str, url: str, image_id: str) -> str:
|
|
| 254 |
else f"/v1/files/image?id={file_id}"
|
| 255 |
)
|
| 256 |
|
| 257 |
-
if fmt
|
| 258 |
return local_url
|
| 259 |
-
return f"" # local_md
|
| 260 |
|
| 261 |
|
| 262 |
def _normalize_image_format(value: str | None) -> str:
|
|
|
|
| 4 |
import base64
|
| 5 |
import re
|
| 6 |
from typing import Any, AsyncGenerator
|
| 7 |
+
from urllib.parse import urlparse
|
| 8 |
|
| 9 |
import orjson
|
| 10 |
|
|
|
|
| 214 |
return save_local_image(raw, mime, image_id)
|
| 215 |
|
| 216 |
|
| 217 |
+
def _is_imagine_public_url(url: str) -> bool:
|
| 218 |
+
try:
|
| 219 |
+
host = urlparse(url or "").hostname or ""
|
| 220 |
+
except Exception:
|
| 221 |
+
return False
|
| 222 |
+
return host.startswith("imagine-public")
|
| 223 |
+
|
| 224 |
+
|
| 225 |
async def _resolve_image(token: str, url: str, image_id: str) -> str:
|
| 226 |
"""Return the image embed text for the response body based on image_format config.
|
| 227 |
|
|
|
|
| 235 |
cfg = get_config()
|
| 236 |
fmt = _normalize_image_format(cfg.get_str("features.image_format", "grok_url"))
|
| 237 |
|
| 238 |
+
proxy_imagine_public = (
|
| 239 |
+
_is_imagine_public_url(url)
|
| 240 |
+
and cfg.get_bool("features.imagine_public_image_proxy", False)
|
| 241 |
+
)
|
| 242 |
+
|
| 243 |
# Formats that don't need downloading
|
| 244 |
+
if fmt == "grok_url" and not proxy_imagine_public:
|
| 245 |
return url
|
| 246 |
+
if fmt == "grok_md" and not proxy_imagine_public:
|
| 247 |
return f""
|
| 248 |
|
| 249 |
# Formats that require downloading
|
|
|
|
| 268 |
else f"/v1/files/image?id={file_id}"
|
| 269 |
)
|
| 270 |
|
| 271 |
+
if fmt in {"grok_url", "local_url"}:
|
| 272 |
return local_url
|
| 273 |
+
return f"" # grok_md / local_md
|
| 274 |
|
| 275 |
|
| 276 |
def _normalize_image_format(value: str | None) -> str:
|
app/products/openai/images.py
CHANGED
|
@@ -8,6 +8,7 @@ import re
|
|
| 8 |
import time
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from typing import Any, AsyncGenerator, Awaitable, Callable
|
|
|
|
| 11 |
|
| 12 |
import orjson
|
| 13 |
|
|
@@ -25,6 +26,7 @@ from app.dataplane.reverse.protocol.xai_chat import (
|
|
| 25 |
StreamAdapter,
|
| 26 |
build_chat_payload,
|
| 27 |
classify_line,
|
|
|
|
| 28 |
)
|
| 29 |
from app.dataplane.reverse.protocol.xai_assets import infer_content_type, resolve_asset_reference, resolve_download_url
|
| 30 |
from app.dataplane.reverse.protocol.xai_image_edit import (
|
|
@@ -50,7 +52,15 @@ from ._format import (
|
|
| 50 |
make_stream_chunk,
|
| 51 |
make_thinking_chunk,
|
| 52 |
)
|
| 53 |
-
from .chat import
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
|
| 55 |
_X_USER_ID_RE = re.compile(r"(?:^|;\s*)x-userid=([^;]+)")
|
| 56 |
|
|
@@ -98,6 +108,21 @@ def _progress_reason(label: str, progress: int, *, completed: int | None = None,
|
|
| 98 |
return reason
|
| 99 |
|
| 100 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 101 |
def _append_reason_update(
|
| 102 |
updates: list[str],
|
| 103 |
label: str,
|
|
@@ -163,6 +188,14 @@ def _extract_image_file_id(url: str) -> str:
|
|
| 163 |
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:32]
|
| 164 |
|
| 165 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
def _save_image(raw: bytes, mime: str, file_id: str) -> str:
|
| 167 |
return save_local_image(raw, mime, file_id)
|
| 168 |
|
|
@@ -188,6 +221,13 @@ async def _resolve_image_output(
|
|
| 188 |
blob_b64: str | None = None,
|
| 189 |
) -> _ImageOutput:
|
| 190 |
fmt = _normalize_response_format(response_format)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 191 |
if fmt == "url" and not _app_url():
|
| 192 |
return _ImageOutput(api_value=url, markdown_value=f"")
|
| 193 |
|
|
@@ -242,7 +282,7 @@ async def generate(
|
|
| 242 |
"""Generate images.
|
| 243 |
|
| 244 |
Routes to the appropriate backend based on model:
|
| 245 |
-
grok-imagine-image-lite → chat endpoint (no aspect-ratio control
|
| 246 |
grok-imagine-image → WebSocket speed mode (super+)
|
| 247 |
grok-imagine-image-pro → WebSocket quality mode (super+)
|
| 248 |
|
|
@@ -315,7 +355,7 @@ async def generate(
|
|
| 315 |
completed=len(completed_ids),
|
| 316 |
total=n,
|
| 317 |
)
|
| 318 |
-
chunk = make_thinking_chunk(response_id, model, reason)
|
| 319 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 320 |
continue
|
| 321 |
if not ev.get("is_final"):
|
|
@@ -327,7 +367,7 @@ async def generate(
|
|
| 327 |
if chat_format and aggregate > last_progress:
|
| 328 |
last_progress = aggregate
|
| 329 |
reason = _progress_reason("图片", aggregate, completed=len(completed_ids), total=n)
|
| 330 |
-
chunk = make_thinking_chunk(response_id, model, reason)
|
| 331 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 332 |
image = await _resolve_image_output(
|
| 333 |
token=token,
|
|
@@ -456,8 +496,7 @@ async def _generate_lite(
|
|
| 456 |
) -> dict | AsyncGenerator[str, None]:
|
| 457 |
"""Generate images via the chat endpoint (Aurora model path).
|
| 458 |
|
| 459 |
-
Does not support aspect ratio or quality control.
|
| 460 |
-
can serve this model.
|
| 461 |
"""
|
| 462 |
response_id = make_response_id()
|
| 463 |
cfg = get_config()
|
|
@@ -498,7 +537,12 @@ async def _generate_lite(
|
|
| 498 |
chunk = make_thinking_chunk(
|
| 499 |
response_id,
|
| 500 |
spec.model_name,
|
| 501 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
)
|
| 503 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 504 |
|
|
@@ -560,10 +604,17 @@ async def _generate_lite(
|
|
| 560 |
# Image editing
|
| 561 |
# ---------------------------------------------------------------------------
|
| 562 |
|
| 563 |
-
_EDIT_MAX_REFERENCES =
|
| 564 |
_EDIT_DEFAULT_SIZE = "1024x1024"
|
| 565 |
_EDIT_MAX_N = 2
|
| 566 |
_EDIT_MAX_ATTEMPTS = 2
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 567 |
|
| 568 |
|
| 569 |
def _normalize_edit_inputs(image_inputs: list[str]) -> list[str]:
|
|
@@ -585,11 +636,16 @@ def _normalize_edit_size(size: str) -> str:
|
|
| 585 |
return _EDIT_DEFAULT_SIZE
|
| 586 |
|
| 587 |
|
| 588 |
-
async def _prepare_edit_reference(
|
|
|
|
|
|
|
| 589 |
"""Upload one edit reference and resolve it to the upstream content URL."""
|
| 590 |
try:
|
| 591 |
file_id, file_uri = await upload_from_input(token, image_input)
|
| 592 |
-
return
|
|
|
|
|
|
|
|
|
|
| 593 |
except ValidationError as exc:
|
| 594 |
raise ValidationError(exc.message, param=f"image.{index}") from exc
|
| 595 |
except UpstreamError as exc:
|
|
@@ -602,9 +658,11 @@ async def _prepare_edit_reference(token: str, image_input: str, index: int) -> s
|
|
| 602 |
raise UpstreamError(f"Image edit reference {index + 1} upload failed: {exc}") from exc
|
| 603 |
|
| 604 |
|
| 605 |
-
async def _prepare_edit_references(
|
|
|
|
|
|
|
| 606 |
"""Upload edit references concurrently and preserve caller order."""
|
| 607 |
-
results: list[
|
| 608 |
|
| 609 |
async def _runner(index: int, image_input: str) -> None:
|
| 610 |
results[index] = await _prepare_edit_reference(token, image_input, index)
|
|
@@ -616,6 +674,20 @@ async def _prepare_edit_references(token: str, image_inputs: list[str]) -> list[
|
|
| 616 |
return [result for result in results if result is not None]
|
| 617 |
|
| 618 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 619 |
def _extract_edit_prompt_and_inputs(messages: list[dict]) -> tuple[str, list[str]]:
|
| 620 |
"""Extract the final prompt and ordered image references from messages."""
|
| 621 |
prompt = ""
|
|
@@ -748,6 +820,7 @@ async def _collect_edit_final_urls(
|
|
| 748 |
obj = orjson.loads(data)
|
| 749 |
except Exception:
|
| 750 |
continue
|
|
|
|
| 751 |
stream = extract_streaming_response(obj)
|
| 752 |
if stream and progress_cb is not None:
|
| 753 |
index = _parse_image_index(stream.get("imageIndex"))
|
|
@@ -910,59 +983,96 @@ async def _run_lite_request(
|
|
| 910 |
if _acct_dir is None:
|
| 911 |
raise RateLimitError("Account directory not initialised")
|
| 912 |
|
| 913 |
-
|
| 914 |
-
|
| 915 |
-
|
| 916 |
-
now_s_override = now_s(),
|
| 917 |
-
)
|
| 918 |
-
if acct is None:
|
| 919 |
-
raise RateLimitError("No available accounts for image generation")
|
| 920 |
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
| 943 |
-
|
| 944 |
-
|
| 945 |
-
|
| 946 |
-
|
| 947 |
-
|
| 948 |
-
|
| 949 |
-
|
| 950 |
-
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
|
| 956 |
-
|
| 957 |
-
|
| 958 |
-
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
|
| 962 |
-
|
| 963 |
-
|
| 964 |
-
|
| 965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
|
| 967 |
|
| 968 |
async def _run_lite_batch(
|
|
@@ -1026,16 +1136,19 @@ async def edit(
|
|
| 1026 |
|
| 1027 |
token = acct.token
|
| 1028 |
response_id = make_response_id()
|
|
|
|
| 1029 |
|
| 1030 |
try:
|
| 1031 |
-
|
| 1032 |
-
if not
|
| 1033 |
raise UpstreamError("All image uploads failed; cannot proceed with image edit")
|
|
|
|
|
|
|
| 1034 |
|
| 1035 |
post = await create_media_post(
|
| 1036 |
token,
|
| 1037 |
media_type=IMAGE_POST_MEDIA_TYPE,
|
| 1038 |
-
prompt=
|
| 1039 |
)
|
| 1040 |
post_data = post.get("post")
|
| 1041 |
if not isinstance(post_data, dict):
|
|
@@ -1043,6 +1156,9 @@ async def edit(
|
|
| 1043 |
parent_post_id = str(post_data.get("id") or "").strip()
|
| 1044 |
if not parent_post_id:
|
| 1045 |
raise UpstreamError("Image edit create-post returned no post id")
|
|
|
|
|
|
|
|
|
|
| 1046 |
except Exception:
|
| 1047 |
await _acct_dir.release(acct)
|
| 1048 |
raise
|
|
@@ -1065,7 +1181,7 @@ async def edit(
|
|
| 1065 |
task = asyncio.create_task(
|
| 1066 |
_collect_edit_images(
|
| 1067 |
token=token,
|
| 1068 |
-
prompt=
|
| 1069 |
image_references=image_references,
|
| 1070 |
parent_post_id=parent_post_id,
|
| 1071 |
requested_n=n,
|
|
@@ -1084,7 +1200,12 @@ async def edit(
|
|
| 1084 |
chunk = make_thinking_chunk(
|
| 1085 |
response_id,
|
| 1086 |
model,
|
| 1087 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1088 |
)
|
| 1089 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 1090 |
images = await task
|
|
@@ -1129,7 +1250,7 @@ async def edit(
|
|
| 1129 |
|
| 1130 |
images = await _collect_edit_images(
|
| 1131 |
token=token,
|
| 1132 |
-
prompt=
|
| 1133 |
image_references=image_references,
|
| 1134 |
parent_post_id=parent_post_id,
|
| 1135 |
requested_n=n,
|
|
|
|
| 8 |
import time
|
| 9 |
from dataclasses import dataclass
|
| 10 |
from typing import Any, AsyncGenerator, Awaitable, Callable
|
| 11 |
+
from urllib.parse import urlparse
|
| 12 |
|
| 13 |
import orjson
|
| 14 |
|
|
|
|
| 26 |
StreamAdapter,
|
| 27 |
build_chat_payload,
|
| 28 |
classify_line,
|
| 29 |
+
raise_for_stream_error,
|
| 30 |
)
|
| 31 |
from app.dataplane.reverse.protocol.xai_assets import infer_content_type, resolve_asset_reference, resolve_download_url
|
| 32 |
from app.dataplane.reverse.protocol.xai_image_edit import (
|
|
|
|
| 52 |
make_stream_chunk,
|
| 53 |
make_thinking_chunk,
|
| 54 |
)
|
| 55 |
+
from .chat import (
|
| 56 |
+
_configured_retry_codes,
|
| 57 |
+
_fail_sync,
|
| 58 |
+
_feedback_kind,
|
| 59 |
+
_log_task_exception,
|
| 60 |
+
_quota_sync,
|
| 61 |
+
_should_retry_upstream,
|
| 62 |
+
)
|
| 63 |
+
from app.products._account_selection import selection_max_retries
|
| 64 |
|
| 65 |
_X_USER_ID_RE = re.compile(r"(?:^|;\s*)x-userid=([^;]+)")
|
| 66 |
|
|
|
|
| 108 |
return reason
|
| 109 |
|
| 110 |
|
| 111 |
+
def _progress_reason_delta(
|
| 112 |
+
label: str,
|
| 113 |
+
progress: int,
|
| 114 |
+
*,
|
| 115 |
+
completed: int | None = None,
|
| 116 |
+
total: int | None = None,
|
| 117 |
+
) -> str:
|
| 118 |
+
return _progress_reason(
|
| 119 |
+
label,
|
| 120 |
+
progress,
|
| 121 |
+
completed=completed,
|
| 122 |
+
total=total,
|
| 123 |
+
) + "\n"
|
| 124 |
+
|
| 125 |
+
|
| 126 |
def _append_reason_update(
|
| 127 |
updates: list[str],
|
| 128 |
label: str,
|
|
|
|
| 188 |
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:32]
|
| 189 |
|
| 190 |
|
| 191 |
+
def _is_imagine_public_url(url: str) -> bool:
|
| 192 |
+
try:
|
| 193 |
+
host = urlparse(url or "").hostname or ""
|
| 194 |
+
except Exception:
|
| 195 |
+
return False
|
| 196 |
+
return host.startswith("imagine-public")
|
| 197 |
+
|
| 198 |
+
|
| 199 |
def _save_image(raw: bytes, mime: str, file_id: str) -> str:
|
| 200 |
return save_local_image(raw, mime, file_id)
|
| 201 |
|
|
|
|
| 221 |
blob_b64: str | None = None,
|
| 222 |
) -> _ImageOutput:
|
| 223 |
fmt = _normalize_response_format(response_format)
|
| 224 |
+
cfg = get_config()
|
| 225 |
+
if (
|
| 226 |
+
fmt == "url"
|
| 227 |
+
and _is_imagine_public_url(url)
|
| 228 |
+
and not cfg.get_bool("features.imagine_public_image_proxy", False)
|
| 229 |
+
):
|
| 230 |
+
return _ImageOutput(api_value=url, markdown_value=f"")
|
| 231 |
if fmt == "url" and not _app_url():
|
| 232 |
return _ImageOutput(api_value=url, markdown_value=f"")
|
| 233 |
|
|
|
|
| 282 |
"""Generate images.
|
| 283 |
|
| 284 |
Routes to the appropriate backend based on model:
|
| 285 |
+
grok-imagine-image-lite → chat endpoint (fast quota, no aspect-ratio control)
|
| 286 |
grok-imagine-image → WebSocket speed mode (super+)
|
| 287 |
grok-imagine-image-pro → WebSocket quality mode (super+)
|
| 288 |
|
|
|
|
| 355 |
completed=len(completed_ids),
|
| 356 |
total=n,
|
| 357 |
)
|
| 358 |
+
chunk = make_thinking_chunk(response_id, model, reason + "\n")
|
| 359 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 360 |
continue
|
| 361 |
if not ev.get("is_final"):
|
|
|
|
| 367 |
if chat_format and aggregate > last_progress:
|
| 368 |
last_progress = aggregate
|
| 369 |
reason = _progress_reason("图片", aggregate, completed=len(completed_ids), total=n)
|
| 370 |
+
chunk = make_thinking_chunk(response_id, model, reason + "\n")
|
| 371 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 372 |
image = await _resolve_image_output(
|
| 373 |
token=token,
|
|
|
|
| 496 |
) -> dict | AsyncGenerator[str, None]:
|
| 497 |
"""Generate images via the chat endpoint (Aurora model path).
|
| 498 |
|
| 499 |
+
Does not support aspect ratio or quality control. It uses fast quota.
|
|
|
|
| 500 |
"""
|
| 501 |
response_id = make_response_id()
|
| 502 |
cfg = get_config()
|
|
|
|
| 537 |
chunk = make_thinking_chunk(
|
| 538 |
response_id,
|
| 539 |
spec.model_name,
|
| 540 |
+
_progress_reason_delta(
|
| 541 |
+
"图片",
|
| 542 |
+
aggregate,
|
| 543 |
+
completed=completed,
|
| 544 |
+
total=n,
|
| 545 |
+
),
|
| 546 |
)
|
| 547 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 548 |
|
|
|
|
| 604 |
# Image editing
|
| 605 |
# ---------------------------------------------------------------------------
|
| 606 |
|
| 607 |
+
_EDIT_MAX_REFERENCES = 7
|
| 608 |
_EDIT_DEFAULT_SIZE = "1024x1024"
|
| 609 |
_EDIT_MAX_N = 2
|
| 610 |
_EDIT_MAX_ATTEMPTS = 2
|
| 611 |
+
_EDIT_IMAGE_PLACEHOLDER_RE = re.compile(r"@IMAGE(\d+)\b", re.IGNORECASE)
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
@dataclass(slots=True)
|
| 615 |
+
class _EditReference:
|
| 616 |
+
file_id: str
|
| 617 |
+
content_url: str
|
| 618 |
|
| 619 |
|
| 620 |
def _normalize_edit_inputs(image_inputs: list[str]) -> list[str]:
|
|
|
|
| 636 |
return _EDIT_DEFAULT_SIZE
|
| 637 |
|
| 638 |
|
| 639 |
+
async def _prepare_edit_reference(
|
| 640 |
+
token: str, image_input: str, index: int
|
| 641 |
+
) -> _EditReference:
|
| 642 |
"""Upload one edit reference and resolve it to the upstream content URL."""
|
| 643 |
try:
|
| 644 |
file_id, file_uri = await upload_from_input(token, image_input)
|
| 645 |
+
return _EditReference(
|
| 646 |
+
file_id=file_id,
|
| 647 |
+
content_url=resolve_uploaded_asset_reference(token, file_id, file_uri),
|
| 648 |
+
)
|
| 649 |
except ValidationError as exc:
|
| 650 |
raise ValidationError(exc.message, param=f"image.{index}") from exc
|
| 651 |
except UpstreamError as exc:
|
|
|
|
| 658 |
raise UpstreamError(f"Image edit reference {index + 1} upload failed: {exc}") from exc
|
| 659 |
|
| 660 |
|
| 661 |
+
async def _prepare_edit_references(
|
| 662 |
+
token: str, image_inputs: list[str]
|
| 663 |
+
) -> list[_EditReference]:
|
| 664 |
"""Upload edit references concurrently and preserve caller order."""
|
| 665 |
+
results: list[_EditReference | None] = [None] * len(image_inputs)
|
| 666 |
|
| 667 |
async def _runner(index: int, image_input: str) -> None:
|
| 668 |
results[index] = await _prepare_edit_reference(token, image_input, index)
|
|
|
|
| 674 |
return [result for result in results if result is not None]
|
| 675 |
|
| 676 |
|
| 677 |
+
def _replace_edit_image_placeholders(
|
| 678 |
+
prompt: str, references: list[_EditReference]
|
| 679 |
+
) -> str:
|
| 680 |
+
"""Replace @IMAGE1-style placeholders with uploaded asset IDs."""
|
| 681 |
+
|
| 682 |
+
def _replace(match: re.Match[str]) -> str:
|
| 683 |
+
image_number = int(match.group(1))
|
| 684 |
+
if image_number < 1 or image_number > len(references):
|
| 685 |
+
return match.group(0)
|
| 686 |
+
return f"@{references[image_number - 1].file_id}"
|
| 687 |
+
|
| 688 |
+
return _EDIT_IMAGE_PLACEHOLDER_RE.sub(_replace, prompt)
|
| 689 |
+
|
| 690 |
+
|
| 691 |
def _extract_edit_prompt_and_inputs(messages: list[dict]) -> tuple[str, list[str]]:
|
| 692 |
"""Extract the final prompt and ordered image references from messages."""
|
| 693 |
prompt = ""
|
|
|
|
| 820 |
obj = orjson.loads(data)
|
| 821 |
except Exception:
|
| 822 |
continue
|
| 823 |
+
raise_for_stream_error(obj)
|
| 824 |
stream = extract_streaming_response(obj)
|
| 825 |
if stream and progress_cb is not None:
|
| 826 |
index = _parse_image_index(stream.get("imageIndex"))
|
|
|
|
| 983 |
if _acct_dir is None:
|
| 984 |
raise RateLimitError("Account directory not initialised")
|
| 985 |
|
| 986 |
+
max_retries = selection_max_retries()
|
| 987 |
+
retry_codes = _configured_retry_codes(get_config())
|
| 988 |
+
excluded: list[str] = []
|
|
|
|
|
|
|
|
|
|
|
|
|
| 989 |
|
| 990 |
+
for attempt in range(max_retries + 1):
|
| 991 |
+
acct = await _acct_dir.reserve(
|
| 992 |
+
pool_candidates=spec.pool_candidates(),
|
| 993 |
+
mode_id=int(spec.mode_id),
|
| 994 |
+
now_s_override=now_s(),
|
| 995 |
+
exclude_tokens=excluded or None,
|
| 996 |
+
)
|
| 997 |
+
if acct is None:
|
| 998 |
+
raise RateLimitError("No available accounts for image generation")
|
| 999 |
+
|
| 1000 |
+
token = acct.token
|
| 1001 |
+
adapter = StreamAdapter()
|
| 1002 |
+
success = False
|
| 1003 |
+
retry = False
|
| 1004 |
+
fail_exc: BaseException | None = None
|
| 1005 |
+
|
| 1006 |
+
try:
|
| 1007 |
+
async for line in _stream_lite_generate(
|
| 1008 |
+
token,
|
| 1009 |
+
prompt,
|
| 1010 |
+
spec.mode_id,
|
| 1011 |
+
timeout_s=timeout_s,
|
| 1012 |
+
):
|
| 1013 |
+
ev_type, data = classify_line(line)
|
| 1014 |
+
if ev_type == "done":
|
| 1015 |
+
break
|
| 1016 |
+
if ev_type != "data" or not data:
|
| 1017 |
+
continue
|
| 1018 |
+
for ev in adapter.feed(data):
|
| 1019 |
+
if ev.kind == "image_progress":
|
| 1020 |
+
if progress_cb is not None:
|
| 1021 |
+
try:
|
| 1022 |
+
await progress_cb(_clamp_progress(int(ev.content or "0")))
|
| 1023 |
+
except ValueError:
|
| 1024 |
+
pass
|
| 1025 |
+
if ev.kind == "image" and ev.content:
|
| 1026 |
+
if progress_cb is not None:
|
| 1027 |
+
await progress_cb(100)
|
| 1028 |
+
image = await _resolve_image_output(
|
| 1029 |
+
token=token,
|
| 1030 |
+
url=ev.content,
|
| 1031 |
+
response_format=response_format,
|
| 1032 |
+
)
|
| 1033 |
+
success = True
|
| 1034 |
+
return image
|
| 1035 |
+
raise UpstreamError("Image generation returned no images")
|
| 1036 |
+
except UpstreamError as exc:
|
| 1037 |
+
fail_exc = exc
|
| 1038 |
+
if _should_retry_upstream(exc, retry_codes) and attempt < max_retries:
|
| 1039 |
+
retry = True
|
| 1040 |
+
logger.warning(
|
| 1041 |
+
"lite image retry scheduled: attempt={}/{} status={} token={}...",
|
| 1042 |
+
attempt + 1,
|
| 1043 |
+
max_retries,
|
| 1044 |
+
exc.status,
|
| 1045 |
+
token[:8],
|
| 1046 |
+
)
|
| 1047 |
+
else:
|
| 1048 |
+
raise
|
| 1049 |
+
except BaseException as exc:
|
| 1050 |
+
fail_exc = exc
|
| 1051 |
+
raise
|
| 1052 |
+
finally:
|
| 1053 |
+
await _acct_dir.release(acct)
|
| 1054 |
+
kind = (
|
| 1055 |
+
FeedbackKind.SUCCESS
|
| 1056 |
+
if success
|
| 1057 |
+
else _feedback_kind(fail_exc)
|
| 1058 |
+
if fail_exc
|
| 1059 |
+
else FeedbackKind.SERVER_ERROR
|
| 1060 |
+
)
|
| 1061 |
+
await _acct_dir.feedback(token, kind, int(spec.mode_id))
|
| 1062 |
+
if success:
|
| 1063 |
+
asyncio.create_task(
|
| 1064 |
+
_quota_sync(token, int(spec.mode_id))
|
| 1065 |
+
).add_done_callback(_log_task_exception)
|
| 1066 |
+
else:
|
| 1067 |
+
asyncio.create_task(
|
| 1068 |
+
_fail_sync(token, int(spec.mode_id), fail_exc)
|
| 1069 |
+
).add_done_callback(_log_task_exception)
|
| 1070 |
+
|
| 1071 |
+
if retry:
|
| 1072 |
+
excluded.append(token)
|
| 1073 |
+
continue
|
| 1074 |
+
|
| 1075 |
+
raise RateLimitError("No available accounts for image generation")
|
| 1076 |
|
| 1077 |
|
| 1078 |
async def _run_lite_batch(
|
|
|
|
| 1136 |
|
| 1137 |
token = acct.token
|
| 1138 |
response_id = make_response_id()
|
| 1139 |
+
edit_prompt = prompt
|
| 1140 |
|
| 1141 |
try:
|
| 1142 |
+
edit_references = await _prepare_edit_references(token, image_inputs)
|
| 1143 |
+
if not edit_references:
|
| 1144 |
raise UpstreamError("All image uploads failed; cannot proceed with image edit")
|
| 1145 |
+
edit_prompt = _replace_edit_image_placeholders(prompt, edit_references)
|
| 1146 |
+
image_references = [ref.content_url for ref in edit_references]
|
| 1147 |
|
| 1148 |
post = await create_media_post(
|
| 1149 |
token,
|
| 1150 |
media_type=IMAGE_POST_MEDIA_TYPE,
|
| 1151 |
+
prompt=edit_prompt,
|
| 1152 |
)
|
| 1153 |
post_data = post.get("post")
|
| 1154 |
if not isinstance(post_data, dict):
|
|
|
|
| 1156 |
parent_post_id = str(post_data.get("id") or "").strip()
|
| 1157 |
if not parent_post_id:
|
| 1158 |
raise UpstreamError("Image edit create-post returned no post id")
|
| 1159 |
+
post_prompt = post_data.get("originalPrompt") or post_data.get("prompt")
|
| 1160 |
+
if isinstance(post_prompt, str) and post_prompt.strip():
|
| 1161 |
+
edit_prompt = post_prompt.strip()
|
| 1162 |
except Exception:
|
| 1163 |
await _acct_dir.release(acct)
|
| 1164 |
raise
|
|
|
|
| 1181 |
task = asyncio.create_task(
|
| 1182 |
_collect_edit_images(
|
| 1183 |
token=token,
|
| 1184 |
+
prompt=edit_prompt,
|
| 1185 |
image_references=image_references,
|
| 1186 |
parent_post_id=parent_post_id,
|
| 1187 |
requested_n=n,
|
|
|
|
| 1200 |
chunk = make_thinking_chunk(
|
| 1201 |
response_id,
|
| 1202 |
model,
|
| 1203 |
+
_progress_reason_delta(
|
| 1204 |
+
"图片",
|
| 1205 |
+
aggregate,
|
| 1206 |
+
completed=completed,
|
| 1207 |
+
total=n,
|
| 1208 |
+
),
|
| 1209 |
)
|
| 1210 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 1211 |
images = await task
|
|
|
|
| 1250 |
|
| 1251 |
images = await _collect_edit_images(
|
| 1252 |
token=token,
|
| 1253 |
+
prompt=edit_prompt,
|
| 1254 |
image_references=image_references,
|
| 1255 |
parent_post_id=parent_post_id,
|
| 1256 |
requested_n=n,
|
app/products/openai/router.py
CHANGED
|
@@ -16,6 +16,7 @@ from app.platform.logging.logger import logger
|
|
| 16 |
from app.platform.storage import image_files_dir, video_files_dir
|
| 17 |
from app.control.model import registry as model_registry
|
| 18 |
from app.control.model.spec import ModelSpec
|
|
|
|
| 19 |
from .schemas import (
|
| 20 |
ChatCompletionRequest,
|
| 21 |
ImageGenerationRequest,
|
|
@@ -48,8 +49,11 @@ async def _available_pools(request: Request) -> frozenset[str]:
|
|
| 48 |
def _model_available_for_pools(spec: ModelSpec, pools: frozenset[str]) -> bool:
|
| 49 |
if not spec.enabled:
|
| 50 |
return False
|
| 51 |
-
|
| 52 |
-
|
|
|
|
|
|
|
|
|
|
| 53 |
|
| 54 |
|
| 55 |
# ---------------------------------------------------------------------------
|
|
@@ -478,7 +482,7 @@ async def videos_create(
|
|
| 478 |
if input_reference:
|
| 479 |
references_payload = [
|
| 480 |
{"image_url": await _upload_to_data_uri(f, param="input_reference")}
|
| 481 |
-
for f in input_reference[:
|
| 482 |
]
|
| 483 |
|
| 484 |
result = await create_video(
|
|
|
|
| 16 |
from app.platform.storage import image_files_dir, video_files_dir
|
| 17 |
from app.control.model import registry as model_registry
|
| 18 |
from app.control.model.spec import ModelSpec
|
| 19 |
+
from app.control.account.quota_defaults import supports_mode
|
| 20 |
from .schemas import (
|
| 21 |
ChatCompletionRequest,
|
| 22 |
ImageGenerationRequest,
|
|
|
|
| 49 |
def _model_available_for_pools(spec: ModelSpec, pools: frozenset[str]) -> bool:
|
| 50 |
if not spec.enabled:
|
| 51 |
return False
|
| 52 |
+
for pool_id in spec.pool_candidates():
|
| 53 |
+
pool = _POOL_ID_TO_NAME[pool_id]
|
| 54 |
+
if pool in pools and supports_mode(pool, int(spec.mode_id)):
|
| 55 |
+
return True
|
| 56 |
+
return False
|
| 57 |
|
| 58 |
|
| 59 |
# ---------------------------------------------------------------------------
|
|
|
|
| 482 |
if input_reference:
|
| 483 |
references_payload = [
|
| 484 |
{"image_url": await _upload_to_data_uri(f, param="input_reference")}
|
| 485 |
+
for f in input_reference[:7]
|
| 486 |
]
|
| 487 |
|
| 488 |
result = await create_video(
|
app/products/openai/video.py
CHANGED
|
@@ -38,7 +38,7 @@ from app.dataplane.reverse.protocol.xai_assets import (
|
|
| 38 |
resolve_asset_reference,
|
| 39 |
resolve_download_url,
|
| 40 |
)
|
| 41 |
-
from app.dataplane.reverse.protocol.xai_chat import classify_line
|
| 42 |
from app.dataplane.reverse.runtime.endpoint_table import CHAT
|
| 43 |
from app.dataplane.reverse.transport.asset_upload import (
|
| 44 |
resolve_uploaded_asset_reference,
|
|
@@ -56,7 +56,7 @@ from .chat import _fail_sync, _quota_sync, _feedback_kind
|
|
| 56 |
|
| 57 |
_IMAGE_MEDIA_TYPE = "MEDIA_POST_TYPE_IMAGE"
|
| 58 |
_VIDEO_MEDIA_TYPE = "MEDIA_POST_TYPE_VIDEO"
|
| 59 |
-
_VIDEO_MODEL_NAME = "
|
| 60 |
_VIDEO_QUALITY = "standard"
|
| 61 |
_VIDEO_OBJECT = "video"
|
| 62 |
_VIDEO_JOB_TTL_S = 3600
|
|
@@ -143,6 +143,10 @@ def _progress_reason(progress: int) -> str:
|
|
| 143 |
return f"视频正在生成 {max(0, min(100, int(progress)))}%"
|
| 144 |
|
| 145 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 146 |
def _coerce_seconds(value: str | int | None) -> int:
|
| 147 |
if value is None:
|
| 148 |
return 6
|
|
@@ -230,7 +234,6 @@ def _video_create_payload(
|
|
| 230 |
"temporary": True,
|
| 231 |
"modelName": _VIDEO_MODEL_NAME,
|
| 232 |
"message": _build_message(prompt, preset),
|
| 233 |
-
"toolOverrides": {"videoGen": True},
|
| 234 |
"enableSideBySide": True,
|
| 235 |
"responseMetadata": {
|
| 236 |
"experiments": [],
|
|
@@ -262,7 +265,6 @@ def _video_extend_payload(
|
|
| 262 |
"temporary": True,
|
| 263 |
"modelName": _VIDEO_MODEL_NAME,
|
| 264 |
"message": _build_message(prompt, preset),
|
| 265 |
-
"toolOverrides": {"videoGen": True},
|
| 266 |
"enableSideBySide": True,
|
| 267 |
"responseMetadata": {
|
| 268 |
"experiments": [],
|
|
@@ -434,16 +436,45 @@ async def _prepare_video_references(
|
|
| 434 |
input_references: list[dict[str, Any]],
|
| 435 |
) -> list[_VideoReference]:
|
| 436 |
"""Upload multiple video references concurrently and preserve order."""
|
| 437 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 438 |
|
| 439 |
-
|
| 440 |
-
results[index] = await _prepare_video_reference(token, ref)
|
| 441 |
|
| 442 |
-
async with asyncio.TaskGroup() as tg:
|
| 443 |
-
for index, ref in enumerate(input_references):
|
| 444 |
-
tg.create_task(_runner(index, ref), name=f"video-ref-{index}")
|
| 445 |
|
| 446 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 447 |
|
| 448 |
|
| 449 |
async def _collect_video_segment(
|
|
@@ -458,6 +489,7 @@ async def _collect_video_segment(
|
|
| 458 |
final_asset_id = ""
|
| 459 |
final_thumbnail = ""
|
| 460 |
video_post_id = ""
|
|
|
|
| 461 |
|
| 462 |
async for line in _stream_video_request(
|
| 463 |
token,
|
|
@@ -470,10 +502,12 @@ async def _collect_video_segment(
|
|
| 470 |
break
|
| 471 |
if event_type != "data" or not data:
|
| 472 |
continue
|
|
|
|
| 473 |
try:
|
| 474 |
obj = orjson.loads(data)
|
| 475 |
except Exception:
|
| 476 |
continue
|
|
|
|
| 477 |
|
| 478 |
stream = _extract_streaming_video_response(obj)
|
| 479 |
if stream:
|
|
@@ -511,10 +545,14 @@ async def _collect_video_segment(
|
|
| 511 |
|
| 512 |
if not final_url and final_asset_id:
|
| 513 |
raise UpstreamError(
|
| 514 |
-
"Video segment returned only assetId without a resolvable URL"
|
|
|
|
| 515 |
)
|
| 516 |
if not final_url:
|
| 517 |
-
raise UpstreamError(
|
|
|
|
|
|
|
|
|
|
| 518 |
|
| 519 |
return _VideoArtifact(
|
| 520 |
video_url=final_url,
|
|
@@ -534,7 +572,12 @@ async def _download_video_bytes(token: str, url: str) -> tuple[bytes, str]:
|
|
| 534 |
raise
|
| 535 |
except Exception as exc:
|
| 536 |
raise UpstreamError(f"Video download failed: {exc}") from exc
|
| 537 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 538 |
|
| 539 |
|
| 540 |
def _save_video_bytes(raw: bytes, file_id: str) -> Path:
|
|
@@ -578,7 +621,7 @@ async def _resolve_video_output(*, token: str, url: str, file_id: str) -> str:
|
|
| 578 |
raw, _mime = await _download_video_bytes(token, url)
|
| 579 |
await asyncio.to_thread(_save_video_bytes, raw, file_id)
|
| 580 |
except Exception as exc:
|
| 581 |
-
logger.
|
| 582 |
return url if fmt == "local_url" else _render_video_html(url)
|
| 583 |
|
| 584 |
local_url = _local_video_url(file_id)
|
|
@@ -867,7 +910,7 @@ async def _run_video_job(
|
|
| 867 |
logger.exception("video job failed: job_id={} error={}", job.id, exc)
|
| 868 |
async with _VIDEO_JOBS_LOCK:
|
| 869 |
job.status = "failed"
|
| 870 |
-
job.error = _job_error_payload(
|
| 871 |
|
| 872 |
|
| 873 |
async def create_video(
|
|
@@ -993,7 +1036,7 @@ def _extract_video_prompt_and_reference(
|
|
| 993 |
|
| 994 |
input_references: list[dict[str, Any]] | None = None
|
| 995 |
if reference_urls:
|
| 996 |
-
input_references = [{"image_url": url} for url in reference_urls[:
|
| 997 |
return prompt, input_references
|
| 998 |
|
| 999 |
|
|
@@ -1061,7 +1104,7 @@ async def completions(
|
|
| 1061 |
if progress > last_progress:
|
| 1062 |
last_progress = progress
|
| 1063 |
chunk = make_thinking_chunk(
|
| 1064 |
-
response_id, model,
|
| 1065 |
)
|
| 1066 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 1067 |
|
|
|
|
| 38 |
resolve_asset_reference,
|
| 39 |
resolve_download_url,
|
| 40 |
)
|
| 41 |
+
from app.dataplane.reverse.protocol.xai_chat import classify_line, raise_for_stream_error
|
| 42 |
from app.dataplane.reverse.runtime.endpoint_table import CHAT
|
| 43 |
from app.dataplane.reverse.transport.asset_upload import (
|
| 44 |
resolve_uploaded_asset_reference,
|
|
|
|
| 56 |
|
| 57 |
_IMAGE_MEDIA_TYPE = "MEDIA_POST_TYPE_IMAGE"
|
| 58 |
_VIDEO_MEDIA_TYPE = "MEDIA_POST_TYPE_VIDEO"
|
| 59 |
+
_VIDEO_MODEL_NAME = "imagine-video-gen"
|
| 60 |
_VIDEO_QUALITY = "standard"
|
| 61 |
_VIDEO_OBJECT = "video"
|
| 62 |
_VIDEO_JOB_TTL_S = 3600
|
|
|
|
| 143 |
return f"视频正在生成 {max(0, min(100, int(progress)))}%"
|
| 144 |
|
| 145 |
|
| 146 |
+
def _progress_reason_delta(progress: int) -> str:
|
| 147 |
+
return _progress_reason(progress) + "\n"
|
| 148 |
+
|
| 149 |
+
|
| 150 |
def _coerce_seconds(value: str | int | None) -> int:
|
| 151 |
if value is None:
|
| 152 |
return 6
|
|
|
|
| 234 |
"temporary": True,
|
| 235 |
"modelName": _VIDEO_MODEL_NAME,
|
| 236 |
"message": _build_message(prompt, preset),
|
|
|
|
| 237 |
"enableSideBySide": True,
|
| 238 |
"responseMetadata": {
|
| 239 |
"experiments": [],
|
|
|
|
| 265 |
"temporary": True,
|
| 266 |
"modelName": _VIDEO_MODEL_NAME,
|
| 267 |
"message": _build_message(prompt, preset),
|
|
|
|
| 268 |
"enableSideBySide": True,
|
| 269 |
"responseMetadata": {
|
| 270 |
"experiments": [],
|
|
|
|
| 436 |
input_references: list[dict[str, Any]],
|
| 437 |
) -> list[_VideoReference]:
|
| 438 |
"""Upload multiple video references concurrently and preserve order."""
|
| 439 |
+
tasks = [
|
| 440 |
+
_prepare_video_reference(token, ref)
|
| 441 |
+
for ref in input_references
|
| 442 |
+
]
|
| 443 |
+
results = await asyncio.gather(*tasks, return_exceptions=True)
|
| 444 |
+
failures: list[tuple[int, BaseException]] = [
|
| 445 |
+
(index, result)
|
| 446 |
+
for index, result in enumerate(results)
|
| 447 |
+
if isinstance(result, BaseException)
|
| 448 |
+
]
|
| 449 |
+
if failures:
|
| 450 |
+
index, exc = failures[0]
|
| 451 |
+
message = f"Video input reference {index + 1} failed: {_exception_message(exc)}"
|
| 452 |
+
if len(failures) > 1:
|
| 453 |
+
message += f" ({len(failures)} references failed)"
|
| 454 |
+
if isinstance(exc, ValidationError):
|
| 455 |
+
raise ValidationError(message, param=exc.param) from exc
|
| 456 |
+
if isinstance(exc, UpstreamError):
|
| 457 |
+
raise UpstreamError(
|
| 458 |
+
message,
|
| 459 |
+
status=exc.status,
|
| 460 |
+
body=exc.details.get("body", ""),
|
| 461 |
+
) from exc
|
| 462 |
+
raise UpstreamError(message) from exc
|
| 463 |
|
| 464 |
+
return [r for r in results if isinstance(r, _VideoReference)]
|
|
|
|
| 465 |
|
|
|
|
|
|
|
|
|
|
| 466 |
|
| 467 |
+
def _exception_message(exc: BaseException) -> str:
|
| 468 |
+
if isinstance(exc, BaseExceptionGroup):
|
| 469 |
+
messages = [
|
| 470 |
+
_exception_message(child)
|
| 471 |
+
for child in exc.exceptions
|
| 472 |
+
if not isinstance(child, asyncio.CancelledError)
|
| 473 |
+
]
|
| 474 |
+
return "; ".join(message for message in messages if message) or str(exc)
|
| 475 |
+
if isinstance(exc, AppError):
|
| 476 |
+
return exc.message
|
| 477 |
+
return str(exc)
|
| 478 |
|
| 479 |
|
| 480 |
async def _collect_video_segment(
|
|
|
|
| 489 |
final_asset_id = ""
|
| 490 |
final_thumbnail = ""
|
| 491 |
video_post_id = ""
|
| 492 |
+
stream_data_items: list[str] = []
|
| 493 |
|
| 494 |
async for line in _stream_video_request(
|
| 495 |
token,
|
|
|
|
| 502 |
break
|
| 503 |
if event_type != "data" or not data:
|
| 504 |
continue
|
| 505 |
+
stream_data_items.append(data)
|
| 506 |
try:
|
| 507 |
obj = orjson.loads(data)
|
| 508 |
except Exception:
|
| 509 |
continue
|
| 510 |
+
raise_for_stream_error(obj)
|
| 511 |
|
| 512 |
stream = _extract_streaming_video_response(obj)
|
| 513 |
if stream:
|
|
|
|
| 545 |
|
| 546 |
if not final_url and final_asset_id:
|
| 547 |
raise UpstreamError(
|
| 548 |
+
"Video segment returned only assetId without a resolvable URL",
|
| 549 |
+
body="\n".join(stream_data_items),
|
| 550 |
)
|
| 551 |
if not final_url:
|
| 552 |
+
raise UpstreamError(
|
| 553 |
+
"Video generation returned no final video URL",
|
| 554 |
+
body="\n".join(stream_data_items),
|
| 555 |
+
)
|
| 556 |
|
| 557 |
return _VideoArtifact(
|
| 558 |
video_url=final_url,
|
|
|
|
| 572 |
raise
|
| 573 |
except Exception as exc:
|
| 574 |
raise UpstreamError(f"Video download failed: {exc}") from exc
|
| 575 |
+
raw = b"".join(chunks)
|
| 576 |
+
if not raw:
|
| 577 |
+
raise UpstreamError("Video download returned empty content", status=502)
|
| 578 |
+
if raw.lstrip()[:1] in {b"<", b"{"}:
|
| 579 |
+
raise UpstreamError("Video download returned non-video content", status=502)
|
| 580 |
+
return raw, (content_type or "video/mp4")
|
| 581 |
|
| 582 |
|
| 583 |
def _save_video_bytes(raw: bytes, file_id: str) -> Path:
|
|
|
|
| 621 |
raw, _mime = await _download_video_bytes(token, url)
|
| 622 |
await asyncio.to_thread(_save_video_bytes, raw, file_id)
|
| 623 |
except Exception as exc:
|
| 624 |
+
logger.debug("video download fallback_to=upstream_url error={}", exc)
|
| 625 |
return url if fmt == "local_url" else _render_video_html(url)
|
| 626 |
|
| 627 |
local_url = _local_video_url(file_id)
|
|
|
|
| 910 |
logger.exception("video job failed: job_id={} error={}", job.id, exc)
|
| 911 |
async with _VIDEO_JOBS_LOCK:
|
| 912 |
job.status = "failed"
|
| 913 |
+
job.error = _job_error_payload(_exception_message(exc))
|
| 914 |
|
| 915 |
|
| 916 |
async def create_video(
|
|
|
|
| 1036 |
|
| 1037 |
input_references: list[dict[str, Any]] | None = None
|
| 1038 |
if reference_urls:
|
| 1039 |
+
input_references = [{"image_url": url} for url in reference_urls[:7]]
|
| 1040 |
return prompt, input_references
|
| 1041 |
|
| 1042 |
|
|
|
|
| 1104 |
if progress > last_progress:
|
| 1105 |
last_progress = progress
|
| 1106 |
chunk = make_thinking_chunk(
|
| 1107 |
+
response_id, model, _progress_reason_delta(progress)
|
| 1108 |
)
|
| 1109 |
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
|
| 1110 |
|
app/products/web/admin/tokens.py
CHANGED
|
@@ -87,6 +87,11 @@ class ToggleTokenDisabledRequest(BaseModel):
|
|
| 87 |
disabled: bool
|
| 88 |
|
| 89 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 90 |
class TokenImportItem(BaseModel):
|
| 91 |
token: str
|
| 92 |
tags: list[str] = []
|
|
@@ -358,6 +363,69 @@ async def toggle_token_disabled(
|
|
| 358 |
return _json({"status": "success", "token": token, "disabled": False})
|
| 359 |
|
| 360 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 361 |
@router.put("/tokens/pool")
|
| 362 |
async def replace_pool(
|
| 363 |
req: ReplacePoolRequest,
|
|
|
|
| 87 |
disabled: bool
|
| 88 |
|
| 89 |
|
| 90 |
+
class ToggleTokensDisabledRequest(BaseModel):
|
| 91 |
+
tokens: list[str]
|
| 92 |
+
disabled: bool
|
| 93 |
+
|
| 94 |
+
|
| 95 |
class TokenImportItem(BaseModel):
|
| 96 |
token: str
|
| 97 |
tags: list[str] = []
|
|
|
|
| 363 |
return _json({"status": "success", "token": token, "disabled": False})
|
| 364 |
|
| 365 |
|
| 366 |
+
@router.post("/tokens/disabled/batch")
|
| 367 |
+
async def toggle_tokens_disabled(
|
| 368 |
+
req: ToggleTokensDisabledRequest,
|
| 369 |
+
repo: "AccountRepository" = Depends(get_repo),
|
| 370 |
+
):
|
| 371 |
+
cleaned: list[str] = []
|
| 372 |
+
seen: set[str] = set()
|
| 373 |
+
for raw in req.tokens:
|
| 374 |
+
token = _sanitize(raw)
|
| 375 |
+
if token and token not in seen:
|
| 376 |
+
seen.add(token)
|
| 377 |
+
cleaned.append(token)
|
| 378 |
+
if not cleaned:
|
| 379 |
+
raise ValidationError("No valid tokens provided", param="tokens")
|
| 380 |
+
|
| 381 |
+
records = await repo.get_accounts(cleaned)
|
| 382 |
+
if not records:
|
| 383 |
+
raise AppError(
|
| 384 |
+
"No matching accounts found",
|
| 385 |
+
kind=ErrorKind.VALIDATION,
|
| 386 |
+
code="account_not_found",
|
| 387 |
+
status=404,
|
| 388 |
+
)
|
| 389 |
+
|
| 390 |
+
ts = now_ms()
|
| 391 |
+
patches: list[AccountPatch] = []
|
| 392 |
+
for record in records:
|
| 393 |
+
if req.disabled:
|
| 394 |
+
patches.append(AccountPatch(
|
| 395 |
+
token=record.token,
|
| 396 |
+
status=AccountStatus.DISABLED,
|
| 397 |
+
state_reason="operator_disabled",
|
| 398 |
+
ext_merge={
|
| 399 |
+
**record.ext,
|
| 400 |
+
"disabled_at": ts,
|
| 401 |
+
"disabled_reason": "operator_disabled",
|
| 402 |
+
},
|
| 403 |
+
))
|
| 404 |
+
else:
|
| 405 |
+
patches.append(AccountPatch(
|
| 406 |
+
token=record.token,
|
| 407 |
+
status=AccountStatus.ACTIVE,
|
| 408 |
+
clear_failures=True,
|
| 409 |
+
))
|
| 410 |
+
|
| 411 |
+
result = await repo.patch_accounts(patches)
|
| 412 |
+
logger.info(
|
| 413 |
+
"admin tokens disabled batch updated: disabled={} requested_count={} patched_count={}",
|
| 414 |
+
req.disabled,
|
| 415 |
+
len(cleaned),
|
| 416 |
+
result.patched,
|
| 417 |
+
)
|
| 418 |
+
return _json({
|
| 419 |
+
"status": "success",
|
| 420 |
+
"disabled": req.disabled,
|
| 421 |
+
"summary": {
|
| 422 |
+
"total": len(cleaned),
|
| 423 |
+
"ok": result.patched,
|
| 424 |
+
"fail": max(0, len(cleaned) - result.patched),
|
| 425 |
+
},
|
| 426 |
+
})
|
| 427 |
+
|
| 428 |
+
|
| 429 |
@router.put("/tokens/pool")
|
| 430 |
async def replace_pool(
|
| 431 |
req: ReplacePoolRequest,
|
app/products/web/webui/voice.py
CHANGED
|
@@ -4,7 +4,6 @@ from fastapi import APIRouter, Depends
|
|
| 4 |
from pydantic import BaseModel
|
| 5 |
|
| 6 |
from app.platform.errors import AppError, RateLimitError, UpstreamError
|
| 7 |
-
from app.platform.logging.logger import logger
|
| 8 |
from app.platform.runtime.clock import now_s
|
| 9 |
from app.platform.auth.middleware import verify_webui_key
|
| 10 |
|
|
@@ -32,11 +31,15 @@ async def voice_token(request: VoiceTokenRequest):
|
|
| 32 |
if _acct_dir is None:
|
| 33 |
raise RateLimitError("Account directory not initialised")
|
| 34 |
|
| 35 |
-
# Voice uses
|
| 36 |
from app.control.model.enums import ModeId
|
| 37 |
|
| 38 |
ts = now_s()
|
| 39 |
-
acct = await _acct_dir.reserve(
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
if acct is None:
|
| 41 |
raise RateLimitError("No available tokens for voice mode")
|
| 42 |
|
|
|
|
| 4 |
from pydantic import BaseModel
|
| 5 |
|
| 6 |
from app.platform.errors import AppError, RateLimitError, UpstreamError
|
|
|
|
| 7 |
from app.platform.runtime.clock import now_s
|
| 8 |
from app.platform.auth.middleware import verify_webui_key
|
| 9 |
|
|
|
|
| 31 |
if _acct_dir is None:
|
| 32 |
raise RateLimitError("Account directory not initialised")
|
| 33 |
|
| 34 |
+
# Voice uses auto mode, which is available on super/heavy pools only.
|
| 35 |
from app.control.model.enums import ModeId
|
| 36 |
|
| 37 |
ts = now_s()
|
| 38 |
+
acct = await _acct_dir.reserve(
|
| 39 |
+
pool_candidates=(1, 2),
|
| 40 |
+
mode_id=int(ModeId.AUTO),
|
| 41 |
+
now_s_override=ts,
|
| 42 |
+
)
|
| 43 |
if acct is None:
|
| 44 |
raise RateLimitError("No available tokens for voice mode")
|
| 45 |
|
app/statics/admin/account.html
CHANGED
|
@@ -939,6 +939,12 @@
|
|
| 939 |
<button type="button" onclick="batchRefreshSel()" id="btn-refresh" class="toolbar-icon-btn" style="display:none">
|
| 940 |
<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M20 11a8 8 0 0 0-14.6-4.6"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.6 4.6"/><path d="M20 20v-5h-5"/></svg>
|
| 941 |
</button>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 942 |
<button type="button" onclick="batchDeleteSel()" id="btn-delete" class="toolbar-icon-btn toolbar-icon-btn-danger" style="display:none">
|
| 943 |
<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M5 7h14"/><path d="M9 7V4h6v3"/><path d="M8 10v7"/><path d="M12 10v7"/><path d="M16 10v7"/><path d="M7 7l1 13h8l1-13"/></svg>
|
| 944 |
</button>
|
|
@@ -1119,6 +1125,8 @@ function applyAccountI18n() {
|
|
| 1119 |
['btn-export', 'account.export', '导出数据'],
|
| 1120 |
['btn-nsfw', 'account.batchNsfw', '开启 NSFW'],
|
| 1121 |
['btn-refresh', 'account.batchRefresh', '刷新选中'],
|
|
|
|
|
|
|
| 1122 |
['btn-delete', 'account.batchDelete', '删除选中'],
|
| 1123 |
['btn-batch-cancel', 'account.cancel', '取消'],
|
| 1124 |
];
|
|
@@ -1580,7 +1588,7 @@ function toggleRow(el) {
|
|
| 1580 |
}
|
| 1581 |
function updateBatchBtns() {
|
| 1582 |
const show = sel.size > 0;
|
| 1583 |
-
['btn-nsfw','btn-refresh','btn-delete'].forEach(id =>
|
| 1584 |
document.getElementById(id).style.display = show ? '' : 'none');
|
| 1585 |
}
|
| 1586 |
|
|
@@ -1811,7 +1819,7 @@ let _batchEs = null;
|
|
| 1811 |
|
| 1812 |
async function _runBatch(endpoint, tokens, label, onDone) {
|
| 1813 |
const btnCancel = document.getElementById('btn-batch-cancel');
|
| 1814 |
-
['btn-nsfw','btn-refresh','btn-delete'].forEach(id =>
|
| 1815 |
document.getElementById(id).style.display = 'none');
|
| 1816 |
btnCancel.style.display = '';
|
| 1817 |
|
|
@@ -1930,6 +1938,45 @@ async function setDisabled(token, disabled) {
|
|
| 1930 |
function disableOne(token) { setDisabled(token, true); }
|
| 1931 |
function restoreOne(token) { setDisabled(token, false); }
|
| 1932 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1933 |
async function batchRefreshSel() {
|
| 1934 |
if (!sel.size) return;
|
| 1935 |
await _runBatch('/batch/refresh', [...sel],
|
|
|
|
| 939 |
<button type="button" onclick="batchRefreshSel()" id="btn-refresh" class="toolbar-icon-btn" style="display:none">
|
| 940 |
<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M20 11a8 8 0 0 0-14.6-4.6"/><path d="M4 4v5h5"/><path d="M4 13a8 8 0 0 0 14.6 4.6"/><path d="M20 20v-5h-5"/></svg>
|
| 941 |
</button>
|
| 942 |
+
<button type="button" onclick="batchDisableSel()" id="btn-disable" class="toolbar-icon-btn" style="display:none">
|
| 943 |
+
<svg viewBox="0 0 24 24" stroke-width="1.8"><circle cx="12" cy="12" r="8"/><path d="M8.5 8.5 15.5 15.5"/></svg>
|
| 944 |
+
</button>
|
| 945 |
+
<button type="button" onclick="batchRestoreSel()" id="btn-restore" class="toolbar-icon-btn" style="display:none">
|
| 946 |
+
<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M3 12a9 9 0 1 0 3-6.708"/><path d="M3 4v5h5"/></svg>
|
| 947 |
+
</button>
|
| 948 |
<button type="button" onclick="batchDeleteSel()" id="btn-delete" class="toolbar-icon-btn toolbar-icon-btn-danger" style="display:none">
|
| 949 |
<svg viewBox="0 0 24 24" stroke-width="1.8"><path d="M5 7h14"/><path d="M9 7V4h6v3"/><path d="M8 10v7"/><path d="M12 10v7"/><path d="M16 10v7"/><path d="M7 7l1 13h8l1-13"/></svg>
|
| 950 |
</button>
|
|
|
|
| 1125 |
['btn-export', 'account.export', '导出数据'],
|
| 1126 |
['btn-nsfw', 'account.batchNsfw', '开启 NSFW'],
|
| 1127 |
['btn-refresh', 'account.batchRefresh', '刷新选中'],
|
| 1128 |
+
['btn-disable', 'account.batchDisable', '禁用选中'],
|
| 1129 |
+
['btn-restore', 'account.batchRestore', '恢复选中'],
|
| 1130 |
['btn-delete', 'account.batchDelete', '删除选中'],
|
| 1131 |
['btn-batch-cancel', 'account.cancel', '取消'],
|
| 1132 |
];
|
|
|
|
| 1588 |
}
|
| 1589 |
function updateBatchBtns() {
|
| 1590 |
const show = sel.size > 0;
|
| 1591 |
+
['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id =>
|
| 1592 |
document.getElementById(id).style.display = show ? '' : 'none');
|
| 1593 |
}
|
| 1594 |
|
|
|
|
| 1819 |
|
| 1820 |
async function _runBatch(endpoint, tokens, label, onDone) {
|
| 1821 |
const btnCancel = document.getElementById('btn-batch-cancel');
|
| 1822 |
+
['btn-nsfw','btn-refresh','btn-disable','btn-restore','btn-delete'].forEach(id =>
|
| 1823 |
document.getElementById(id).style.display = 'none');
|
| 1824 |
btnCancel.style.display = '';
|
| 1825 |
|
|
|
|
| 1938 |
function disableOne(token) { setDisabled(token, true); }
|
| 1939 |
function restoreOne(token) { setDisabled(token, false); }
|
| 1940 |
|
| 1941 |
+
function batchSetDisabled(disabled) {
|
| 1942 |
+
if (!sel.size) return;
|
| 1943 |
+
const tokens = [...sel];
|
| 1944 |
+
const n = tokens.length;
|
| 1945 |
+
const title = disabled
|
| 1946 |
+
? tr('account.batchDisableConfirmTitle', null, '批量禁用账号')
|
| 1947 |
+
: tr('account.batchRestoreConfirmTitle', null, '批量恢复账号');
|
| 1948 |
+
const body = disabled
|
| 1949 |
+
? tr('account.batchDisableConfirmBody', { n }, `确认禁用选中的 <b>${n}</b> 个账户?<br><small style="color:var(--fg-muted)">禁用后这些账号不会参与请求分配,但可随时恢复。</small>`)
|
| 1950 |
+
: tr('account.batchRestoreConfirmBody', { n }, `确认恢复选中的 <b>${n}</b> 个账户?<br><small style="color:var(--fg-muted)">恢复后这些账号将重新参与请求分配。</small>`);
|
| 1951 |
+
|
| 1952 |
+
openConfirm(title, body, async () => {
|
| 1953 |
+
try {
|
| 1954 |
+
showToast(
|
| 1955 |
+
disabled
|
| 1956 |
+
? tr('account.disablingMany', { n }, `正在禁用 ${n} 个账户…`)
|
| 1957 |
+
: tr('account.restoringMany', { n }, `正在恢复 ${n} 个账户…`),
|
| 1958 |
+
'info',
|
| 1959 |
+
);
|
| 1960 |
+
const d = await _api('POST', '/tokens/disabled/batch', { tokens, disabled });
|
| 1961 |
+
const ok = d.summary?.ok ?? 0;
|
| 1962 |
+
const fail = d.summary?.fail ?? 0;
|
| 1963 |
+
showToast(
|
| 1964 |
+
disabled
|
| 1965 |
+
? tr('account.disableManyDone', { ok, fail }, `禁用完成:成功 ${ok} 个,失败 ${fail} 个`)
|
| 1966 |
+
: tr('account.restoreManyDone', { ok, fail }, `恢复完成:成功 ${ok} 个,失败 ${fail} 个`),
|
| 1967 |
+
fail > 0 ? 'error' : 'success',
|
| 1968 |
+
);
|
| 1969 |
+
sel.clear();
|
| 1970 |
+
await load();
|
| 1971 |
+
} catch (e) {
|
| 1972 |
+
showToast(`${tr('account.operationFailed', null, '操作失败')}: ${e.message}`, 'error');
|
| 1973 |
+
}
|
| 1974 |
+
});
|
| 1975 |
+
}
|
| 1976 |
+
|
| 1977 |
+
function batchDisableSel() { batchSetDisabled(true); }
|
| 1978 |
+
function batchRestoreSel() { batchSetDisabled(false); }
|
| 1979 |
+
|
| 1980 |
async function batchRefreshSel() {
|
| 1981 |
if (!sel.size) return;
|
| 1982 |
await _runBatch('/batch/refresh', [...sel],
|
app/statics/admin/config.html
CHANGED
|
@@ -467,9 +467,17 @@ const SCHEMA_DEF = [
|
|
| 467 |
{ value: 'local_md', label: 'Markdown(本地代理)', labelKey: 'config.schema.options.imageFormat.localMarkdown', disabledWhen: 'no_app_url', disabledTip: '请先填写 APP 访问地址', disabledTipKey: 'config.disabledTip.appUrlRequired' },
|
| 468 |
{ value: 'base64', label: 'Base64(内嵌)', labelKey: 'config.schema.options.imageFormat.base64' },
|
| 469 |
],
|
| 470 |
-
desc: 'grok_url / grok_md 直接返回 Grok CDN 地址
|
| 471 |
descKey: 'config.schema.fields.imageFormat.desc',
|
| 472 |
},
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 473 |
{
|
| 474 |
key: 'video_format',
|
| 475 |
label: '视频返回格式',
|
|
@@ -496,7 +504,7 @@ const SCHEMA_DEF = [
|
|
| 496 |
section: 'account.refresh',
|
| 497 |
fields: [
|
| 498 |
{ key: 'enabled', label: '启用配额刷新', labelKey: 'config.schema.fields.refreshEnabled.label', type: 'bool', desc: '开启后自动进入配额刷新模式,关闭后自动进入自动重试模式。', descKey: 'config.schema.fields.refreshEnabled.desc', help: '开启:配额刷新模式,scheduler 周期同步真实配额,选号按评分。\n关闭:自动重试模式,不主动探测 upstream,选号随机,出错自动换号重试最多 5 次。\n建议:万级以上账号关闭,避免主动探测触发 upstream 429。', helpKey: 'config.schema.fields.refreshEnabled.help' },
|
| 499 |
-
{ key: 'basic_interval_sec', label: 'Basic 周期(秒)', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认
|
| 500 |
{ key: 'super_interval_sec', label: 'Super 周期(秒)', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.superInterval.desc' },
|
| 501 |
{ key: 'heavy_interval_sec', label: 'Heavy 周期(秒)', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.heavyInterval.desc' },
|
| 502 |
{ key: 'on_demand_min_interval_sec', label: '按需刷新间隔(秒)', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '请求链路触发刷新(例如收到 429)时的节流间隔。N 秒内重复触发只执行一次,避免批量打爆配额接口。', descKey: 'config.schema.fields.onDemandMinInterval.desc' },
|
|
|
|
| 467 |
{ value: 'local_md', label: 'Markdown(本地代理)', labelKey: 'config.schema.options.imageFormat.localMarkdown', disabledWhen: 'no_app_url', disabledTip: '请先填写 APP 访问地址', disabledTipKey: 'config.disabledTip.appUrlRequired' },
|
| 468 |
{ value: 'base64', label: 'Base64(内嵌)', labelKey: 'config.schema.options.imageFormat.base64' },
|
| 469 |
],
|
| 470 |
+
desc: 'grok_url / grok_md 默认直接返回 Grok CDN 地址;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。开启 Imagine Public 图片代理后,WebSocket 返回的 imagine-public 图片也会本地代理。',
|
| 471 |
descKey: 'config.schema.fields.imageFormat.desc',
|
| 472 |
},
|
| 473 |
+
{
|
| 474 |
+
key: 'imagine_public_image_proxy',
|
| 475 |
+
label: 'Imagine Public 图片代理',
|
| 476 |
+
labelKey: 'config.schema.fields.imaginePublicImageProxy.label',
|
| 477 |
+
type: 'bool',
|
| 478 |
+
desc: '开启后将 WebSocket 返回的 imagine-public 图片下载到服务端,再通过本地 URL 代理分发;关闭时保持公开图片直返。',
|
| 479 |
+
descKey: 'config.schema.fields.imaginePublicImageProxy.desc',
|
| 480 |
+
},
|
| 481 |
{
|
| 482 |
key: 'video_format',
|
| 483 |
label: '视频返回格式',
|
|
|
|
| 504 |
section: 'account.refresh',
|
| 505 |
fields: [
|
| 506 |
{ key: 'enabled', label: '启用配额刷新', labelKey: 'config.schema.fields.refreshEnabled.label', type: 'bool', desc: '开启后自动进入配额刷新模式,关闭后自动进入自动重试模式。', descKey: 'config.schema.fields.refreshEnabled.desc', help: '开启:配额刷新模式,scheduler 周期同步真实配额,选号按评分。\n关闭:自动重试模式,不主动探测 upstream,选号随机,出错自动换号重试最多 5 次。\n建议:万级以上账号关闭,避免主动探测触发 upstream 429。', helpKey: 'config.schema.fields.refreshEnabled.help' },
|
| 507 |
+
{ key: 'basic_interval_sec', label: 'Basic 周期(秒)', labelKey: 'config.schema.fields.basicInterval.label', type: 'number', desc: 'basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 86400s。', descKey: 'config.schema.fields.basicInterval.desc' },
|
| 508 |
{ key: 'super_interval_sec', label: 'Super 周期(秒)', labelKey: 'config.schema.fields.superInterval.label', type: 'number', desc: 'super 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.superInterval.desc' },
|
| 509 |
{ key: 'heavy_interval_sec', label: 'Heavy 周期(秒)', labelKey: 'config.schema.fields.heavyInterval.label', type: 'number', desc: 'heavy 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 7200s。', descKey: 'config.schema.fields.heavyInterval.desc' },
|
| 510 |
{ key: 'on_demand_min_interval_sec', label: '按需刷新间隔(秒)', labelKey: 'config.schema.fields.onDemandMinInterval.label', type: 'number', desc: '请求链路触发刷新(例如收到 429)时的节流间隔。N 秒内重复触发只执行一次,避免批量打爆配额接口。', descKey: 'config.schema.fields.onDemandMinInterval.desc' },
|
app/statics/css/app.css
CHANGED
|
@@ -561,6 +561,11 @@ button{cursor:pointer}
|
|
| 561 |
.msg-card-assistant video{
|
| 562 |
display:block;width:min(100%,400px);max-height:300px;border-radius:16px;background:#111
|
| 563 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 564 |
.msg-card-assistant > *:first-child{margin-top:0}
|
| 565 |
.msg-card-assistant > *:last-child{margin-bottom:0}
|
| 566 |
.msg-card-assistant p,
|
|
@@ -688,6 +693,8 @@ button{cursor:pointer}
|
|
| 688 |
color:#201d19;
|
| 689 |
font-size:12px;
|
| 690 |
letter-spacing:-.01em;
|
|
|
|
|
|
|
| 691 |
appearance:none;
|
| 692 |
background-image:none;
|
| 693 |
}
|
|
|
|
| 561 |
.msg-card-assistant video{
|
| 562 |
display:block;width:min(100%,400px);max-height:300px;border-radius:16px;background:#111
|
| 563 |
}
|
| 564 |
+
.msg-media-error{
|
| 565 |
+
box-sizing:border-box;width:max-content;max-width:100%;margin-top:8px;padding:9px 11px;border-radius:10px;
|
| 566 |
+
background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-size:12px;line-height:1.5;
|
| 567 |
+
white-space:nowrap
|
| 568 |
+
}
|
| 569 |
.msg-card-assistant > *:first-child{margin-top:0}
|
| 570 |
.msg-card-assistant > *:last-child{margin-bottom:0}
|
| 571 |
.msg-card-assistant p,
|
|
|
|
| 693 |
color:#201d19;
|
| 694 |
font-size:12px;
|
| 695 |
letter-spacing:-.01em;
|
| 696 |
+
text-align:right;
|
| 697 |
+
text-align-last:right;
|
| 698 |
appearance:none;
|
| 699 |
background-image:none;
|
| 700 |
}
|
app/statics/i18n/de.json
CHANGED
|
@@ -64,6 +64,8 @@
|
|
| 64 |
"imageRequired": "Für die Bildbearbeitung ist mindestens ein Referenzbild erforderlich",
|
| 65 |
"imageOnly": "Die Bildbearbeitung unterstützt nur Bild-Uploads",
|
| 66 |
"videoImageOnly": "Die Videogenerierung unterstützt nur Bild-Uploads als Referenz",
|
|
|
|
|
|
|
| 67 |
"requestFailed": "Anfrage fehlgeschlagen",
|
| 68 |
"initFailed": "Initialisierung der Chat-Seite fehlgeschlagen"
|
| 69 |
}
|
|
@@ -182,6 +184,8 @@
|
|
| 182 |
"batchNsfw": "NSFW aktivieren",
|
| 183 |
"batchNsfwDisable": "NSFW deaktivieren",
|
| 184 |
"batchRefresh": "Auswahl aktualisieren",
|
|
|
|
|
|
|
| 185 |
"batchDelete": "Auswahl löschen",
|
| 186 |
"colToken": "Token",
|
| 187 |
"colType": "Kontotyp",
|
|
@@ -264,6 +268,14 @@
|
|
| 264 |
"restoreConfirmBody": "{token} wiederherstellen?<br><small style=\"color:var(--fg-muted)\">Das Konto nimmt danach wieder an der Anfrageverteilung teil.</small>",
|
| 265 |
"restoringOne": "Konto wird wiederhergestellt…",
|
| 266 |
"restoreDone": "Konto wiederhergestellt",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"nsfwConfirmTitle": "NSFW aktivieren",
|
| 268 |
"nsfwConfirmBody": "NSFW für <b>{n}</b> ausgewählte Konten aktivieren?",
|
| 269 |
"nsfwEnablingOne": "NSFW wird aktiviert…",
|
|
@@ -383,11 +395,12 @@
|
|
| 383 |
"enableNsfw": { "label": "NSFW-Erzeugung zulassen" },
|
| 384 |
"showSearchSources": { "label": "Quellen an Inhalt anhängen", "desc": "Suchquellen werden immer im Feld search_sources ausgegeben. Diese Option steuert, ob zusätzlich ein ## Sources-Abschnitt an den Inhalt angehängt wird (für Abwärtskompatibilität mit textbasierten Clients)." },
|
| 385 |
"customInstruction": { "label": "Globale Zusatzanweisung" },
|
| 386 |
-
"imageFormat": { "label": "Bildausgabeformat" },
|
|
|
|
| 387 |
"videoFormat": { "label": "Videoausgabeformat" },
|
| 388 |
"refreshEnabled": { "label": "Quotenaktualisierung aktivieren", "desc": "Wenn aktiviert, wechselt das System automatisch in den Quotenaktualisierungsmodus. Wenn deaktiviert, wechselt es automatisch in den Auto-Retry-Modus.", "help": "Ein: Quotenaktualisierung. Der Scheduler synchronisiert regelmäßig echte Quoten, die Auswahl erfolgt nach Bewertung.\nAus: Auto-Retry-Modus. Keine aktive upstream-Abfrage, zufällige Auswahl und automatischer Kontowechsel bei Fehlern mit bis zu 5 Wiederholungen.\nEmpfehlung: Bei mehr als 10.000 Konten ausschalten, um upstream 429 durch aktive Abfragen zu vermeiden." },
|
| 389 |
"maxInflight": { "label": "Max. laufende Anfragen pro Konto", "desc": "Maximale Anzahl gleichzeitig laufender Anfragen pro Konto. Konten am Limit werden von der Auswahl übersprungen (für beide Modi gemeinsam)." },
|
| 390 |
-
"basicInterval": { "label": "Basic-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Basic-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert:
|
| 391 |
"superInterval": { "label": "Super-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Super-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." },
|
| 392 |
"heavyInterval": { "label": "Heavy-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Heavy-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." },
|
| 393 |
"usageConcurrency": { "label": "Aktualisierungs-Konkurrenz", "desc": "Maximale Anzahl paralleler usage-Aufrufe während der Quotenaktualisierung." },
|
|
|
|
| 64 |
"imageRequired": "Für die Bildbearbeitung ist mindestens ein Referenzbild erforderlich",
|
| 65 |
"imageOnly": "Die Bildbearbeitung unterstützt nur Bild-Uploads",
|
| 66 |
"videoImageOnly": "Die Videogenerierung unterstützt nur Bild-Uploads als Referenz",
|
| 67 |
+
"imageProxyRequired": "Bild konnte nicht geladen werden. Konfigurieren Sie die APP-Basis-URL und ändern Sie das Bildausgabeformat in local_url, local_md oder base64.",
|
| 68 |
+
"videoProxyRequired": "Das Laden des Videos ergab 403. Öffnen Sie die Admin-Seite, konfigurieren Sie die APP-Basis-URL und ändern Sie das Videoausgabeformat in den lokalen Proxy-Modus (local_url oder local_html). Versuchen Sie es dann erneut.",
|
| 69 |
"requestFailed": "Anfrage fehlgeschlagen",
|
| 70 |
"initFailed": "Initialisierung der Chat-Seite fehlgeschlagen"
|
| 71 |
}
|
|
|
|
| 184 |
"batchNsfw": "NSFW aktivieren",
|
| 185 |
"batchNsfwDisable": "NSFW deaktivieren",
|
| 186 |
"batchRefresh": "Auswahl aktualisieren",
|
| 187 |
+
"batchDisable": "Auswahl deaktivieren",
|
| 188 |
+
"batchRestore": "Auswahl wiederherstellen",
|
| 189 |
"batchDelete": "Auswahl löschen",
|
| 190 |
"colToken": "Token",
|
| 191 |
"colType": "Kontotyp",
|
|
|
|
| 268 |
"restoreConfirmBody": "{token} wiederherstellen?<br><small style=\"color:var(--fg-muted)\">Das Konto nimmt danach wieder an der Anfrageverteilung teil.</small>",
|
| 269 |
"restoringOne": "Konto wird wiederhergestellt…",
|
| 270 |
"restoreDone": "Konto wiederhergestellt",
|
| 271 |
+
"batchDisableConfirmTitle": "Konten deaktivieren",
|
| 272 |
+
"batchDisableConfirmBody": "Die ausgewählten <b>{n}</b> Konten deaktivieren?<br><small style=\"color:var(--fg-muted)\">Deaktivierte Konten werden nicht für Anfragen verwendet und können später wiederhergestellt werden.</small>",
|
| 273 |
+
"disablingMany": "{n} Konten werden deaktiviert…",
|
| 274 |
+
"disableManyDone": "Deaktivierung abgeschlossen: {ok} erfolgreich, {fail} fehlgeschlagen",
|
| 275 |
+
"batchRestoreConfirmTitle": "Konten wiederherstellen",
|
| 276 |
+
"batchRestoreConfirmBody": "Die ausgewählten <b>{n}</b> Konten wiederherstellen?<br><small style=\"color:var(--fg-muted)\">Wiederhergestellte Konten werden erneut für Anfragen verwendet.</small>",
|
| 277 |
+
"restoringMany": "{n} Konten werden wiederhergestellt…",
|
| 278 |
+
"restoreManyDone": "Wiederherstellung abgeschlossen: {ok} erfolgreich, {fail} fehlgeschlagen",
|
| 279 |
"nsfwConfirmTitle": "NSFW aktivieren",
|
| 280 |
"nsfwConfirmBody": "NSFW für <b>{n}</b> ausgewählte Konten aktivieren?",
|
| 281 |
"nsfwEnablingOne": "NSFW wird aktiviert…",
|
|
|
|
| 395 |
"enableNsfw": { "label": "NSFW-Erzeugung zulassen" },
|
| 396 |
"showSearchSources": { "label": "Quellen an Inhalt anhängen", "desc": "Suchquellen werden immer im Feld search_sources ausgegeben. Diese Option steuert, ob zusätzlich ein ## Sources-Abschnitt an den Inhalt angehängt wird (für Abwärtskompatibilität mit textbasierten Clients)." },
|
| 397 |
"customInstruction": { "label": "Globale Zusatzanweisung" },
|
| 398 |
+
"imageFormat": { "label": "Bildausgabeformat", "desc": "grok_url und grok_md geben die native Grok-CDN-Adresse standardmäßig direkt zurück. local_* Modi laden Assets zuerst auf den Server herunter und stellen sie über den lokalen Proxy bereit. base64 gibt eine eingebettete Data URI zurück. Wenn der Imagine-Public-Bildproxy aktiviert ist, werden vom WebSocket zurückgegebene imagine-public-Bilder ebenfalls lokal proxied." },
|
| 399 |
+
"imaginePublicImageProxy": { "label": "Imagine-Public-Bildproxy", "desc": "Wenn aktiviert, werden vom WebSocket zurückgegebene imagine-public-Bilder auf den Server heruntergeladen und über den lokalen Proxy ausgeliefert. Wenn deaktiviert, werden öffentliche Bilder direkt zurückgegeben." },
|
| 400 |
"videoFormat": { "label": "Videoausgabeformat" },
|
| 401 |
"refreshEnabled": { "label": "Quotenaktualisierung aktivieren", "desc": "Wenn aktiviert, wechselt das System automatisch in den Quotenaktualisierungsmodus. Wenn deaktiviert, wechselt es automatisch in den Auto-Retry-Modus.", "help": "Ein: Quotenaktualisierung. Der Scheduler synchronisiert regelmäßig echte Quoten, die Auswahl erfolgt nach Bewertung.\nAus: Auto-Retry-Modus. Keine aktive upstream-Abfrage, zufällige Auswahl und automatischer Kontowechsel bei Fehlern mit bis zu 5 Wiederholungen.\nEmpfehlung: Bei mehr als 10.000 Konten ausschalten, um upstream 429 durch aktive Abfragen zu vermeiden." },
|
| 402 |
"maxInflight": { "label": "Max. laufende Anfragen pro Konto", "desc": "Maximale Anzahl gleichzeitig laufender Anfragen pro Konto. Konten am Limit werden von der Auswahl übersprungen (für beide Modi gemeinsam)." },
|
| 403 |
+
"basicInterval": { "label": "Basic-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Basic-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 86400s." },
|
| 404 |
"superInterval": { "label": "Super-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Super-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." },
|
| 405 |
"heavyInterval": { "label": "Heavy-Zyklus (s)", "desc": "Gemeinsamer Zyklus für den Heavy-Pool: wird im Quota-Modus für den Hintergrund-Refresh und im Random-Modus für die 429-Abkühlung verwendet. Standardwert: 7200s." },
|
| 406 |
"usageConcurrency": { "label": "Aktualisierungs-Konkurrenz", "desc": "Maximale Anzahl paralleler usage-Aufrufe während der Quotenaktualisierung." },
|
app/statics/i18n/en.json
CHANGED
|
@@ -83,6 +83,8 @@
|
|
| 83 |
"imageRequired": "Image edit requires at least one reference image",
|
| 84 |
"imageOnly": "Image edit only supports image uploads",
|
| 85 |
"videoImageOnly": "Video generation only supports image reference uploads",
|
|
|
|
|
|
|
| 86 |
"requestFailed": "Request failed",
|
| 87 |
"initFailed": "Chat page initialization failed"
|
| 88 |
}
|
|
@@ -183,6 +185,8 @@
|
|
| 183 |
"batchNsfw": "Enable NSFW",
|
| 184 |
"batchNsfwDisable": "Disable NSFW",
|
| 185 |
"batchRefresh": "Refresh Selected",
|
|
|
|
|
|
|
| 186 |
"batchDelete": "Delete Selected",
|
| 187 |
"colToken": "Token",
|
| 188 |
"colType": "Account Type",
|
|
@@ -265,6 +269,14 @@
|
|
| 265 |
"restoreConfirmBody": "Restore {token}?<br><small style=\"color:var(--fg-muted)\">The account will be returned to request allocation.</small>",
|
| 266 |
"restoringOne": "Restoring account…",
|
| 267 |
"restoreDone": "Account restored",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
"nsfwConfirmTitle": "Enable NSFW",
|
| 269 |
"nsfwConfirmBody": "Enable NSFW for <b>{n}</b> selected accounts?",
|
| 270 |
"nsfwEnablingOne": "Enabling NSFW…",
|
|
@@ -431,7 +443,11 @@
|
|
| 431 |
},
|
| 432 |
"imageFormat": {
|
| 433 |
"label": "Image Output Format",
|
| 434 |
-
"desc": "grok_url and grok_md return the native Grok CDN address directly. local_* modes download assets to the server first and re-expose them through the local proxy. base64 returns an inline Data URI."
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
},
|
| 436 |
"videoFormat": {
|
| 437 |
"label": "Video Output Format",
|
|
@@ -448,7 +464,7 @@
|
|
| 448 |
},
|
| 449 |
"basicInterval": {
|
| 450 |
"label": "Basic Cycle (s)",
|
| 451 |
-
"desc": "Shared cycle for the basic pool: used for background refresh in quota mode and for 429 cooldown in random mode. Default
|
| 452 |
},
|
| 453 |
"superInterval": {
|
| 454 |
"label": "Super Cycle (s)",
|
|
|
|
| 83 |
"imageRequired": "Image edit requires at least one reference image",
|
| 84 |
"imageOnly": "Image edit only supports image uploads",
|
| 85 |
"videoImageOnly": "Video generation only supports image reference uploads",
|
| 86 |
+
"imageProxyRequired": "Image failed to load. Set APP Base URL and change image output format to local_url, local_md, or base64.",
|
| 87 |
+
"videoProxyRequired": "Video loading returned 403. Go to the admin page, set the APP Base URL, then change the video output format to local proxy mode (local_url or local_html) and retry.",
|
| 88 |
"requestFailed": "Request failed",
|
| 89 |
"initFailed": "Chat page initialization failed"
|
| 90 |
}
|
|
|
|
| 185 |
"batchNsfw": "Enable NSFW",
|
| 186 |
"batchNsfwDisable": "Disable NSFW",
|
| 187 |
"batchRefresh": "Refresh Selected",
|
| 188 |
+
"batchDisable": "Disable Selected",
|
| 189 |
+
"batchRestore": "Restore Selected",
|
| 190 |
"batchDelete": "Delete Selected",
|
| 191 |
"colToken": "Token",
|
| 192 |
"colType": "Account Type",
|
|
|
|
| 269 |
"restoreConfirmBody": "Restore {token}?<br><small style=\"color:var(--fg-muted)\">The account will be returned to request allocation.</small>",
|
| 270 |
"restoringOne": "Restoring account…",
|
| 271 |
"restoreDone": "Account restored",
|
| 272 |
+
"batchDisableConfirmTitle": "Disable Accounts",
|
| 273 |
+
"batchDisableConfirmBody": "Disable the selected <b>{n}</b> accounts?<br><small style=\"color:var(--fg-muted)\">Disabled accounts are removed from request allocation and can be restored later.</small>",
|
| 274 |
+
"disablingMany": "Disabling {n} accounts…",
|
| 275 |
+
"disableManyDone": "Disable completed: {ok} succeeded, {fail} failed",
|
| 276 |
+
"batchRestoreConfirmTitle": "Restore Accounts",
|
| 277 |
+
"batchRestoreConfirmBody": "Restore the selected <b>{n}</b> accounts?<br><small style=\"color:var(--fg-muted)\">Restored accounts will be returned to request allocation.</small>",
|
| 278 |
+
"restoringMany": "Restoring {n} accounts…",
|
| 279 |
+
"restoreManyDone": "Restore completed: {ok} succeeded, {fail} failed",
|
| 280 |
"nsfwConfirmTitle": "Enable NSFW",
|
| 281 |
"nsfwConfirmBody": "Enable NSFW for <b>{n}</b> selected accounts?",
|
| 282 |
"nsfwEnablingOne": "Enabling NSFW…",
|
|
|
|
| 443 |
},
|
| 444 |
"imageFormat": {
|
| 445 |
"label": "Image Output Format",
|
| 446 |
+
"desc": "grok_url and grok_md return the native Grok CDN address directly by default. local_* modes download assets to the server first and re-expose them through the local proxy. base64 returns an inline Data URI. When Imagine Public Image Proxy is enabled, imagine-public images returned by the WebSocket are also proxied locally."
|
| 447 |
+
},
|
| 448 |
+
"imaginePublicImageProxy": {
|
| 449 |
+
"label": "Imagine Public Image Proxy",
|
| 450 |
+
"desc": "When enabled, imagine-public images returned by the WebSocket are downloaded to the server and delivered through the local proxy. When disabled, public images are returned directly."
|
| 451 |
},
|
| 452 |
"videoFormat": {
|
| 453 |
"label": "Video Output Format",
|
|
|
|
| 464 |
},
|
| 465 |
"basicInterval": {
|
| 466 |
"label": "Basic Cycle (s)",
|
| 467 |
+
"desc": "Shared cycle for the basic pool: used for background refresh in quota mode and for 429 cooldown in random mode. Default 86400s."
|
| 468 |
},
|
| 469 |
"superInterval": {
|
| 470 |
"label": "Super Cycle (s)",
|
app/statics/i18n/es.json
CHANGED
|
@@ -64,6 +64,8 @@
|
|
| 64 |
"imageRequired": "La edición de imagen requiere al menos una imagen de referencia",
|
| 65 |
"imageOnly": "La edición de imagen solo admite cargas de imágenes",
|
| 66 |
"videoImageOnly": "La generación de vídeo solo admite imágenes como referencia",
|
|
|
|
|
|
|
| 67 |
"requestFailed": "La solicitud falló",
|
| 68 |
"initFailed": "La inicialización de la página de chat falló"
|
| 69 |
}
|
|
@@ -182,6 +184,8 @@
|
|
| 182 |
"batchNsfw": "Activar NSFW",
|
| 183 |
"batchNsfwDisable": "Desactivar NSFW",
|
| 184 |
"batchRefresh": "Actualizar selección",
|
|
|
|
|
|
|
| 185 |
"batchDelete": "Eliminar selección",
|
| 186 |
"colToken": "Token",
|
| 187 |
"colType": "Tipo de cuenta",
|
|
@@ -264,6 +268,14 @@
|
|
| 264 |
"restoreConfirmBody": "¿Restaurar {token}?<br><small style=\"color:var(--fg-muted)\">La cuenta volverá a participar en la asignación de solicitudes.</small>",
|
| 265 |
"restoringOne": "Restaurando cuenta…",
|
| 266 |
"restoreDone": "Cuenta restaurada",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"nsfwConfirmTitle": "Activar NSFW",
|
| 268 |
"nsfwConfirmBody": "¿Activar NSFW para <b>{n}</b> cuentas seleccionadas?",
|
| 269 |
"nsfwEnablingOne": "Activando NSFW…",
|
|
@@ -383,11 +395,12 @@
|
|
| 383 |
"enableNsfw": { "label": "Permitir generación NSFW" },
|
| 384 |
"showSearchSources": { "label": "Agregar fuentes al contenido", "desc": "Las fuentes de búsqueda siempre se incluyen en el campo search_sources. Esta opción controla si también se agrega una sección ## Sources al contenido (para compatibilidad con clientes que analizan texto)." },
|
| 385 |
"customInstruction": { "label": "Instrucción suplementaria global" },
|
| 386 |
-
"imageFormat": { "label": "Formato de salida de imagen" },
|
|
|
|
| 387 |
"videoFormat": { "label": "Formato de salida de video" },
|
| 388 |
"refreshEnabled": { "label": "Activar actualización de cuota", "desc": "Al activarlo, el sistema cambia automáticamente al modo de actualización de cuota. Al desactivarlo, cambia automáticamente al modo de reintento automático.", "help": "Activado: modo de actualización de cuota. El scheduler sincroniza periódicamente la cuota real y la selección usa puntuación.\nDesactivado: modo de reintento automático. No comprueba upstream activamente, usa selección aleatoria y cambia de cuenta al fallar hasta 5 reintentos.\nRecomendación: desactívalo con más de 10.000 cuentas para evitar provocar 429 de upstream con comprobaciones activas." },
|
| 389 |
"maxInflight": { "label": "Máximo en curso por cuenta", "desc": "Máximo de solicitudes simultáneas en curso por cuenta. Las cuentas en ese límite se omiten en la selección (compartido por ambos modos)." },
|
| 390 |
-
"basicInterval": { "label": "Ciclo Basic (s)", "desc": "Ciclo compartido del pool Basic: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto:
|
| 391 |
"superInterval": { "label": "Ciclo Super (s)", "desc": "Ciclo compartido del pool Super: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." },
|
| 392 |
"heavyInterval": { "label": "Ciclo Heavy (s)", "desc": "Ciclo compartido del pool Heavy: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." },
|
| 393 |
"usageConcurrency": { "label": "Concurrencia de actualización", "desc": "Número máximo de llamadas usage paralelas permitidas durante la actualización de cuota." },
|
|
|
|
| 64 |
"imageRequired": "La edición de imagen requiere al menos una imagen de referencia",
|
| 65 |
"imageOnly": "La edición de imagen solo admite cargas de imágenes",
|
| 66 |
"videoImageOnly": "La generación de vídeo solo admite imágenes como referencia",
|
| 67 |
+
"imageProxyRequired": "No se pudo cargar la imagen. Configure la URL base de APP y cambie el formato de salida de imagen a local_url, local_md o base64.",
|
| 68 |
+
"videoProxyRequired": "La carga del vídeo devolvió 403. Vaya a la página de administración, configure la URL base de APP y cambie el formato de salida de vídeo al modo de proxy local (local_url o local_html). Después, inténtelo de nuevo.",
|
| 69 |
"requestFailed": "La solicitud falló",
|
| 70 |
"initFailed": "La inicialización de la página de chat falló"
|
| 71 |
}
|
|
|
|
| 184 |
"batchNsfw": "Activar NSFW",
|
| 185 |
"batchNsfwDisable": "Desactivar NSFW",
|
| 186 |
"batchRefresh": "Actualizar selección",
|
| 187 |
+
"batchDisable": "Desactivar selección",
|
| 188 |
+
"batchRestore": "Restaurar selección",
|
| 189 |
"batchDelete": "Eliminar selección",
|
| 190 |
"colToken": "Token",
|
| 191 |
"colType": "Tipo de cuenta",
|
|
|
|
| 268 |
"restoreConfirmBody": "¿Restaurar {token}?<br><small style=\"color:var(--fg-muted)\">La cuenta volverá a participar en la asignación de solicitudes.</small>",
|
| 269 |
"restoringOne": "Restaurando cuenta…",
|
| 270 |
"restoreDone": "Cuenta restaurada",
|
| 271 |
+
"batchDisableConfirmTitle": "Desactivar cuentas",
|
| 272 |
+
"batchDisableConfirmBody": "¿Desactivar las <b>{n}</b> cuentas seleccionadas?<br><small style=\"color:var(--fg-muted)\">Las cuentas desactivadas no participarán en la asignación de solicitudes y podrán restaurarse más tarde.</small>",
|
| 273 |
+
"disablingMany": "Desactivando {n} cuentas…",
|
| 274 |
+
"disableManyDone": "Desactivación completada: {ok} correctas, {fail} fallidas",
|
| 275 |
+
"batchRestoreConfirmTitle": "Restaurar cuentas",
|
| 276 |
+
"batchRestoreConfirmBody": "¿Restaurar las <b>{n}</b> cuentas seleccionadas?<br><small style=\"color:var(--fg-muted)\">Las cuentas restauradas volverán a participar en la asignación de solicitudes.</small>",
|
| 277 |
+
"restoringMany": "Restaurando {n} cuentas…",
|
| 278 |
+
"restoreManyDone": "Restauración completada: {ok} correctas, {fail} fallidas",
|
| 279 |
"nsfwConfirmTitle": "Activar NSFW",
|
| 280 |
"nsfwConfirmBody": "¿Activar NSFW para <b>{n}</b> cuentas seleccionadas?",
|
| 281 |
"nsfwEnablingOne": "Activando NSFW…",
|
|
|
|
| 395 |
"enableNsfw": { "label": "Permitir generación NSFW" },
|
| 396 |
"showSearchSources": { "label": "Agregar fuentes al contenido", "desc": "Las fuentes de búsqueda siempre se incluyen en el campo search_sources. Esta opción controla si también se agrega una sección ## Sources al contenido (para compatibilidad con clientes que analizan texto)." },
|
| 397 |
"customInstruction": { "label": "Instrucción suplementaria global" },
|
| 398 |
+
"imageFormat": { "label": "Formato de salida de imagen", "desc": "grok_url y grok_md devuelven directamente la dirección nativa del CDN de Grok de forma predeterminada. Los modos local_* descargan primero los recursos en el servidor y los vuelven a exponer mediante el proxy local. base64 devuelve un Data URI integrado. Cuando el proxy de imágenes Imagine Public está activado, las imágenes imagine-public devueltas por WebSocket también se sirven mediante el proxy local." },
|
| 399 |
+
"imaginePublicImageProxy": { "label": "Proxy de imágenes Imagine Public", "desc": "Cuando está activado, las imágenes imagine-public devueltas por WebSocket se descargan en el servidor y se entregan mediante el proxy local. Cuando está desactivado, las imágenes públicas se devuelven directamente." },
|
| 400 |
"videoFormat": { "label": "Formato de salida de video" },
|
| 401 |
"refreshEnabled": { "label": "Activar actualización de cuota", "desc": "Al activarlo, el sistema cambia automáticamente al modo de actualización de cuota. Al desactivarlo, cambia automáticamente al modo de reintento automático.", "help": "Activado: modo de actualización de cuota. El scheduler sincroniza periódicamente la cuota real y la selección usa puntuación.\nDesactivado: modo de reintento automático. No comprueba upstream activamente, usa selección aleatoria y cambia de cuenta al fallar hasta 5 reintentos.\nRecomendación: desactívalo con más de 10.000 cuentas para evitar provocar 429 de upstream con comprobaciones activas." },
|
| 402 |
"maxInflight": { "label": "Máximo en curso por cuenta", "desc": "Máximo de solicitudes simultáneas en curso por cuenta. Las cuentas en ese límite se omiten en la selección (compartido por ambos modos)." },
|
| 403 |
+
"basicInterval": { "label": "Ciclo Basic (s)", "desc": "Ciclo compartido del pool Basic: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 86400s." },
|
| 404 |
"superInterval": { "label": "Ciclo Super (s)", "desc": "Ciclo compartido del pool Super: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." },
|
| 405 |
"heavyInterval": { "label": "Ciclo Heavy (s)", "desc": "Ciclo compartido del pool Heavy: se usa para la actualización en segundo plano en modo quota y para el enfriamiento tras 429 en modo random. Valor por defecto: 7200s." },
|
| 406 |
"usageConcurrency": { "label": "Concurrencia de actualización", "desc": "Número máximo de llamadas usage paralelas permitidas durante la actualización de cuota." },
|
app/statics/i18n/fr.json
CHANGED
|
@@ -64,6 +64,8 @@
|
|
| 64 |
"imageRequired": "L’édition d’image nécessite au moins une image de référence",
|
| 65 |
"imageOnly": "L’édition d’image ne prend en charge que les téléversements d’images",
|
| 66 |
"videoImageOnly": "La génération vidéo n’accepte que les images comme référence",
|
|
|
|
|
|
|
| 67 |
"requestFailed": "Échec de la requête",
|
| 68 |
"initFailed": "Échec de l’initialisation de la page de chat"
|
| 69 |
}
|
|
@@ -182,6 +184,8 @@
|
|
| 182 |
"batchNsfw": "Activer NSFW",
|
| 183 |
"batchNsfwDisable": "Désactiver NSFW",
|
| 184 |
"batchRefresh": "Actualiser la sélection",
|
|
|
|
|
|
|
| 185 |
"batchDelete": "Supprimer la sélection",
|
| 186 |
"colToken": "Token",
|
| 187 |
"colType": "Type de compte",
|
|
@@ -264,6 +268,14 @@
|
|
| 264 |
"restoreConfirmBody": "Restaurer {token} ?<br><small style=\"color:var(--fg-muted)\">Le compte reprendra la participation à l’allocation des requêtes.</small>",
|
| 265 |
"restoringOne": "Restauration du compte…",
|
| 266 |
"restoreDone": "Compte restauré",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"nsfwConfirmTitle": "Activer NSFW",
|
| 268 |
"nsfwConfirmBody": "Activer NSFW pour <b>{n}</b> comptes sélectionnés ?",
|
| 269 |
"nsfwEnablingOne": "Activation de NSFW…",
|
|
@@ -383,11 +395,12 @@
|
|
| 383 |
"enableNsfw": { "label": "Autoriser la génération NSFW" },
|
| 384 |
"showSearchSources": { "label": "Ajouter les sources au contenu", "desc": "Les sources de recherche sont toujours présentes dans le champ search_sources. Cette option contrôle si une section ## Sources est également ajoutée au contenu (pour la compatibilité avec les clients analysant le texte)." },
|
| 385 |
"customInstruction": { "label": "Instruction globale supplémentaire" },
|
| 386 |
-
"imageFormat": { "label": "Format de sortie image" },
|
|
|
|
| 387 |
"videoFormat": { "label": "Format de sortie vidéo" },
|
| 388 |
"refreshEnabled": { "label": "Activer l’actualisation du quota", "desc": "Lorsqu’il est activé, le système passe automatiquement en mode d’actualisation du quota. Lorsqu’il est désactivé, il passe automatiquement en mode de relance automatique.", "help": "Activé : mode d’actualisation du quota. Le scheduler synchronise périodiquement le quota réel et la sélection utilise un score.\nDésactivé : mode de relance automatique. Aucune sonde upstream active, sélection aléatoire et changement automatique de compte en cas d’erreur jusqu’à 5 relances.\nRecommandation : désactivez-le au-delà de 10 000 comptes pour éviter de déclencher des 429 upstream par les sondes actives." },
|
| 389 |
"maxInflight": { "label": "Maximum en cours par compte", "desc": "Nombre maximal de requêtes simultanées par compte. Les comptes à cette limite sont ignorés par la sélection (partagé par les deux modes)." },
|
| 390 |
-
"basicInterval": { "label": "Cycle Basic (s)", "desc": "Cycle partagé du pool Basic : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut :
|
| 391 |
"superInterval": { "label": "Cycle Super (s)", "desc": "Cycle partagé du pool Super : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." },
|
| 392 |
"heavyInterval": { "label": "Cycle Heavy (s)", "desc": "Cycle partagé du pool Heavy : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." },
|
| 393 |
"usageConcurrency": { "label": "Concurrence d’actualisation", "desc": "Nombre maximal d’appels usage parallèles autorisés pendant l’actualisation du quota." },
|
|
|
|
| 64 |
"imageRequired": "L’édition d’image nécessite au moins une image de référence",
|
| 65 |
"imageOnly": "L’édition d’image ne prend en charge que les téléversements d’images",
|
| 66 |
"videoImageOnly": "La génération vidéo n’accepte que les images comme référence",
|
| 67 |
+
"imageProxyRequired": "Échec du chargement de l’image. Configurez l’URL de base APP et changez le format de sortie image en local_url, local_md ou base64.",
|
| 68 |
+
"videoProxyRequired": "Le chargement de la vidéo a renvoyé 403. Accédez à la page d’administration, configurez l’URL de base APP, puis passez le format de sortie vidéo en mode proxy local (local_url ou local_html) et réessayez.",
|
| 69 |
"requestFailed": "Échec de la requête",
|
| 70 |
"initFailed": "Échec de l’initialisation de la page de chat"
|
| 71 |
}
|
|
|
|
| 184 |
"batchNsfw": "Activer NSFW",
|
| 185 |
"batchNsfwDisable": "Désactiver NSFW",
|
| 186 |
"batchRefresh": "Actualiser la sélection",
|
| 187 |
+
"batchDisable": "Désactiver la sélection",
|
| 188 |
+
"batchRestore": "Restaurer la sélection",
|
| 189 |
"batchDelete": "Supprimer la sélection",
|
| 190 |
"colToken": "Token",
|
| 191 |
"colType": "Type de compte",
|
|
|
|
| 268 |
"restoreConfirmBody": "Restaurer {token} ?<br><small style=\"color:var(--fg-muted)\">Le compte reprendra la participation à l’allocation des requêtes.</small>",
|
| 269 |
"restoringOne": "Restauration du compte…",
|
| 270 |
"restoreDone": "Compte restauré",
|
| 271 |
+
"batchDisableConfirmTitle": "Désactiver les comptes",
|
| 272 |
+
"batchDisableConfirmBody": "Désactiver les <b>{n}</b> comptes sélectionnés ?<br><small style=\"color:var(--fg-muted)\">Les comptes désactivés ne participeront plus à l’allocation des requêtes et pourront être restaurés plus tard.</small>",
|
| 273 |
+
"disablingMany": "Désactivation de {n} comptes…",
|
| 274 |
+
"disableManyDone": "Désactivation terminée : {ok} réussies, {fail} échouées",
|
| 275 |
+
"batchRestoreConfirmTitle": "Restaurer les comptes",
|
| 276 |
+
"batchRestoreConfirmBody": "Restaurer les <b>{n}</b> comptes sélectionnés ?<br><small style=\"color:var(--fg-muted)\">Les comptes restaurés participeront de nouveau à l’allocation des requêtes.</small>",
|
| 277 |
+
"restoringMany": "Restauration de {n} comptes…",
|
| 278 |
+
"restoreManyDone": "Restauration terminée : {ok} réussies, {fail} échouées",
|
| 279 |
"nsfwConfirmTitle": "Activer NSFW",
|
| 280 |
"nsfwConfirmBody": "Activer NSFW pour <b>{n}</b> comptes sélectionnés ?",
|
| 281 |
"nsfwEnablingOne": "Activation de NSFW…",
|
|
|
|
| 395 |
"enableNsfw": { "label": "Autoriser la génération NSFW" },
|
| 396 |
"showSearchSources": { "label": "Ajouter les sources au contenu", "desc": "Les sources de recherche sont toujours présentes dans le champ search_sources. Cette option contrôle si une section ## Sources est également ajoutée au contenu (pour la compatibilité avec les clients analysant le texte)." },
|
| 397 |
"customInstruction": { "label": "Instruction globale supplémentaire" },
|
| 398 |
+
"imageFormat": { "label": "Format de sortie image", "desc": "grok_url et grok_md renvoient directement l’adresse CDN native de Grok par défaut. Les modes local_* téléchargent d’abord les ressources sur le serveur puis les redistribuent via le proxy local. base64 renvoie une Data URI intégrée. Lorsque le proxy d’images Imagine Public est activé, les images imagine-public renvoyées par le WebSocket sont également servies via le proxy local." },
|
| 399 |
+
"imaginePublicImageProxy": { "label": "Proxy d’images Imagine Public", "desc": "Lorsque cette option est activée, les images imagine-public renvoyées par le WebSocket sont téléchargées sur le serveur puis distribuées via le proxy local. Lorsqu’elle est désactivée, les images publiques sont renvoyées directement." },
|
| 400 |
"videoFormat": { "label": "Format de sortie vidéo" },
|
| 401 |
"refreshEnabled": { "label": "Activer l’actualisation du quota", "desc": "Lorsqu’il est activé, le système passe automatiquement en mode d’actualisation du quota. Lorsqu’il est désactivé, il passe automatiquement en mode de relance automatique.", "help": "Activé : mode d’actualisation du quota. Le scheduler synchronise périodiquement le quota réel et la sélection utilise un score.\nDésactivé : mode de relance automatique. Aucune sonde upstream active, sélection aléatoire et changement automatique de compte en cas d’erreur jusqu’à 5 relances.\nRecommandation : désactivez-le au-delà de 10 000 comptes pour éviter de déclencher des 429 upstream par les sondes actives." },
|
| 402 |
"maxInflight": { "label": "Maximum en cours par compte", "desc": "Nombre maximal de requêtes simultanées par compte. Les comptes à cette limite sont ignorés par la sélection (partagé par les deux modes)." },
|
| 403 |
+
"basicInterval": { "label": "Cycle Basic (s)", "desc": "Cycle partagé du pool Basic : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 86400s." },
|
| 404 |
"superInterval": { "label": "Cycle Super (s)", "desc": "Cycle partagé du pool Super : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." },
|
| 405 |
"heavyInterval": { "label": "Cycle Heavy (s)", "desc": "Cycle partagé du pool Heavy : utilisé pour l’actualisation en arrière-plan en mode quota et pour le refroidissement après 429 en mode random. Valeur par défaut : 7200s." },
|
| 406 |
"usageConcurrency": { "label": "Concurrence d’actualisation", "desc": "Nombre maximal d’appels usage parallèles autorisés pendant l’actualisation du quota." },
|
app/statics/i18n/ja.json
CHANGED
|
@@ -64,6 +64,8 @@
|
|
| 64 |
"imageRequired": "画像編集には少なくとも 1 枚の参照画像が必要です",
|
| 65 |
"imageOnly": "画像編集では画像ファイルのみアップロードできます",
|
| 66 |
"videoImageOnly": "動画生成では参照として画像のみアップロードできます",
|
|
|
|
|
|
|
| 67 |
"requestFailed": "リクエストに失敗しました",
|
| 68 |
"initFailed": "チャットページの初期化に失敗しました"
|
| 69 |
}
|
|
@@ -182,6 +184,8 @@
|
|
| 182 |
"batchNsfw": "NSFW を有効化",
|
| 183 |
"batchNsfwDisable": "NSFW を無効化",
|
| 184 |
"batchRefresh": "選択項目を更新",
|
|
|
|
|
|
|
| 185 |
"batchDelete": "選択項目を削除",
|
| 186 |
"colToken": "トークン",
|
| 187 |
"colType": "アカウント種別",
|
|
@@ -264,6 +268,14 @@
|
|
| 264 |
"restoreConfirmBody": "{token} を復元しますか?<br><small style=\"color:var(--fg-muted)\">復元後、このアカウントは再びリクエスト割り当てに参加します。</small>",
|
| 265 |
"restoringOne": "アカウントを復元しています…",
|
| 266 |
"restoreDone": "アカウントを復元しました",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 267 |
"nsfwConfirmTitle": "NSFW を有効化",
|
| 268 |
"nsfwConfirmBody": "選択した <b>{n}</b> 件のアカウントで NSFW を有効にしますか?",
|
| 269 |
"nsfwEnablingOne": "NSFW を有効化しています…",
|
|
@@ -383,11 +395,12 @@
|
|
| 383 |
"enableNsfw": { "label": "NSFW 生成を許可" },
|
| 384 |
"showSearchSources": { "label": "コンテンツにソースを追加", "desc": "検索ソースは常に search_sources フィールドに出力されます。このオプションは、テキスト解析クライアントとの互換性のために ## Sources セクションをコンテンツに追加するかどうかを制御します。" },
|
| 385 |
"customInstruction": { "label": "グローバル補助指示" },
|
| 386 |
-
"imageFormat": { "label": "画像出力形式" },
|
|
|
|
| 387 |
"videoFormat": { "label": "動画出力形式" },
|
| 388 |
"refreshEnabled": { "label": "クォータ更新を有効化", "desc": "オンにすると自動的にクォータ更新モードへ切り替わり、オフにすると自動的に自動再試行モードへ切り替わります。", "help": "オン:クォータ更新モード。scheduler が実クォータを定期同期し、スコアでアカウントを選択します。\nオフ:自動再試行モード。upstream を能動的に確認せず、ランダム選択し、エラー時は最大 5 回まで別アカウントへ自動切替します。\n推奨:1 万件以上のアカウントではオフにして、能動確認による upstream 429 を避けてください。" },
|
| 389 |
"maxInflight": { "label": "アカウントごとの同時実行上限", "desc": "1 アカウント��同時に処理中にできるリクエスト数の上限です。上限に達したアカウントは選択時にスキップされます(両モード共通)。" },
|
| 390 |
-
"basicInterval": { "label": "Basic 周期(秒)", "desc": "Basic プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は
|
| 391 |
"superInterval": { "label": "Super 周期(秒)", "desc": "Super プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" },
|
| 392 |
"heavyInterval": { "label": "Heavy 周期(秒)", "desc": "Heavy プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" },
|
| 393 |
"usageConcurrency": { "label": "更新並列数", "desc": "クォータ更新中に許可する usage 呼び出しの最大並列数です。" },
|
|
|
|
| 64 |
"imageRequired": "画像編集には少なくとも 1 枚の参照画像が必要です",
|
| 65 |
"imageOnly": "画像編集では画像ファイルのみアップロードできます",
|
| 66 |
"videoImageOnly": "動画生成では参照として画像のみアップロードできます",
|
| 67 |
+
"imageProxyRequired": "画像を読み込めませんでした。APP ベース URL を設定し、画像の返却形式を local_url、local_md、または base64 に変更してください。",
|
| 68 |
+
"videoProxyRequired": "動画の読み込みで 403 が返されました。管理ページで APP ベース URL を設定し、動画の返却形式をローカルプロキシモード(local_url または local_html)に変更してから再試行してください。",
|
| 69 |
"requestFailed": "リクエストに失敗しました",
|
| 70 |
"initFailed": "チャットページの初期化に失敗しました"
|
| 71 |
}
|
|
|
|
| 184 |
"batchNsfw": "NSFW を有効化",
|
| 185 |
"batchNsfwDisable": "NSFW を無効化",
|
| 186 |
"batchRefresh": "選択項目を更新",
|
| 187 |
+
"batchDisable": "選択項目を無効化",
|
| 188 |
+
"batchRestore": "選択項目を復元",
|
| 189 |
"batchDelete": "選択項目を削除",
|
| 190 |
"colToken": "トークン",
|
| 191 |
"colType": "アカウント種別",
|
|
|
|
| 268 |
"restoreConfirmBody": "{token} を復元しますか?<br><small style=\"color:var(--fg-muted)\">復元後、このアカウントは再びリクエスト割り当てに参加します。</small>",
|
| 269 |
"restoringOne": "アカウントを復元しています…",
|
| 270 |
"restoreDone": "アカウントを復元しました",
|
| 271 |
+
"batchDisableConfirmTitle": "アカウントを一括無効化",
|
| 272 |
+
"batchDisableConfirmBody": "選択した <b>{n}</b> 件のアカウントを無効化しますか?<br><small style=\"color:var(--fg-muted)\">無効化されたアカウントはリクエスト割り当てに参加せず、後で復元できます。</small>",
|
| 273 |
+
"disablingMany": "{n} 件のアカウントを無効化しています…",
|
| 274 |
+
"disableManyDone": "無効化完了: 成功 {ok} 件、失敗 {fail} 件",
|
| 275 |
+
"batchRestoreConfirmTitle": "アカウントを一括復元",
|
| 276 |
+
"batchRestoreConfirmBody": "選択した <b>{n}</b> 件のアカウントを復元しますか?<br><small style=\"color:var(--fg-muted)\">復元後、これらのアカウントは再びリクエスト割り当てに参加します。</small>",
|
| 277 |
+
"restoringMany": "{n} 件のアカウントを復元しています…",
|
| 278 |
+
"restoreManyDone": "復元完了: 成功 {ok} 件、失敗 {fail} 件",
|
| 279 |
"nsfwConfirmTitle": "NSFW を有効化",
|
| 280 |
"nsfwConfirmBody": "選択した <b>{n}</b> 件のアカウントで NSFW を有効にしますか?",
|
| 281 |
"nsfwEnablingOne": "NSFW を有効化しています…",
|
|
|
|
| 395 |
"enableNsfw": { "label": "NSFW 生成を許可" },
|
| 396 |
"showSearchSources": { "label": "コンテンツにソースを追加", "desc": "検索ソースは常に search_sources フィールドに出力されます。このオプションは、テキスト解析クライアントとの互換性のために ## Sources セクションをコンテンツに追加するかどうかを制御します。" },
|
| 397 |
"customInstruction": { "label": "グローバル補助指示" },
|
| 398 |
+
"imageFormat": { "label": "画像出力形式", "desc": "grok_url と grok_md は既定では Grok CDN のネイティブ URL を直接返します。local_* モードでは、先にサーバーへダウンロードしてからローカルプロキシ経由で再配信します。base64 は埋め込み Data URI を返します。Imagine Public 画像プロキシを有効にすると、WebSocket が返す imagine-public 画像もローカルプロキシ経由になります。" },
|
| 399 |
+
"imaginePublicImageProxy": { "label": "Imagine Public 画像プロキシ", "desc": "有効にすると、WebSocket が返す imagine-public 画像をサーバーにダウンロードし、ローカルプロキシ経由で配信します。無効の場合、公開画像はそのまま返します。" },
|
| 400 |
"videoFormat": { "label": "動画出力形式" },
|
| 401 |
"refreshEnabled": { "label": "クォータ更新を有効化", "desc": "オンにすると自動的にクォータ更新モードへ切り替わり、オフにすると自動的に自動再試行モードへ切り替わります。", "help": "オン:クォータ更新モード。scheduler が実クォータを定期同期し、スコアでアカウントを選択します。\nオフ:自動再試行モード。upstream を能動的に確認せず、ランダム選択し、エラー時は最大 5 回まで別アカウントへ自動切替します。\n推奨:1 万件以上のアカウントではオフにして、能動確認による upstream 429 を避けてください。" },
|
| 402 |
"maxInflight": { "label": "アカウントごとの同時実行上限", "desc": "1 アカウント��同時に処理中にできるリクエスト数の上限です。上限に達したアカウントは選択時にスキップされます(両モード共通)。" },
|
| 403 |
+
"basicInterval": { "label": "Basic 周期(秒)", "desc": "Basic プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 86400s です。" },
|
| 404 |
"superInterval": { "label": "Super 周期(秒)", "desc": "Super プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" },
|
| 405 |
"heavyInterval": { "label": "Heavy 周期(秒)", "desc": "Heavy プールの共通周期です。quota モードではバックグラウンド更新に、random モードでは 429 クールダウンに使われます。既定値は 7200s です。" },
|
| 406 |
"usageConcurrency": { "label": "更新並列数", "desc": "クォータ更新中に許可する usage 呼び出しの最大並列数です。" },
|
app/statics/i18n/zh.json
CHANGED
|
@@ -83,6 +83,8 @@
|
|
| 83 |
"imageRequired": "图像编辑至少需要上传一张参考图",
|
| 84 |
"imageOnly": "图像编辑只支持上传图片",
|
| 85 |
"videoImageOnly": "视频生成只支持上传图片作为参考图",
|
|
|
|
|
|
|
| 86 |
"requestFailed": "请求失败",
|
| 87 |
"initFailed": "聊天页面初始化失败"
|
| 88 |
}
|
|
@@ -183,6 +185,8 @@
|
|
| 183 |
"batchNsfw": "开启 NSFW",
|
| 184 |
"batchNsfwDisable": "关闭 NSFW",
|
| 185 |
"batchRefresh": "刷新选中",
|
|
|
|
|
|
|
| 186 |
"batchDelete": "删除选中",
|
| 187 |
"colToken": "Token",
|
| 188 |
"colType": "账户类型",
|
|
@@ -265,6 +269,14 @@
|
|
| 265 |
"restoreConfirmBody": "确认恢复 {token}?<br><small style=\"color:var(--fg-muted)\">恢复后该账号将重新参与请求分配。</small>",
|
| 266 |
"restoringOne": "正在恢复账号…",
|
| 267 |
"restoreDone": "账号已恢复",
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 268 |
"nsfwConfirmTitle": "启用 NSFW",
|
| 269 |
"nsfwConfirmBody": "确认为选中的 <b>{n}</b> 个账户启用 NSFW?",
|
| 270 |
"nsfwEnablingOne": "正在启用 NSFW…",
|
|
@@ -431,7 +443,11 @@
|
|
| 431 |
},
|
| 432 |
"imageFormat": {
|
| 433 |
"label": "图片返回格式",
|
| 434 |
-
"desc": "grok_url / grok_md 直接返回 Grok CDN 地址
|
|
|
|
|
|
|
|
|
|
|
|
|
| 435 |
},
|
| 436 |
"videoFormat": {
|
| 437 |
"label": "视频返回格式",
|
|
@@ -448,7 +464,7 @@
|
|
| 448 |
},
|
| 449 |
"basicInterval": {
|
| 450 |
"label": "Basic 周期(秒)",
|
| 451 |
-
"desc": "basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认
|
| 452 |
},
|
| 453 |
"superInterval": {
|
| 454 |
"label": "Super 周期(秒)",
|
|
|
|
| 83 |
"imageRequired": "图像编辑至少需要上传一张参考图",
|
| 84 |
"imageOnly": "图像编辑只支持上传图片",
|
| 85 |
"videoImageOnly": "视频生成只支持上传图片作为参考图",
|
| 86 |
+
"imageProxyRequired": "图片加载失败。请先设置 APP 访问地址,并将图片返回格式改为 local_url、local_md 或 base64。",
|
| 87 |
+
"videoProxyRequired": "视频加载 403,请前往管理页面设置 APP 访问地址后,将频返回格式改为本地代理( local_url 或 local_html)模式后重试。",
|
| 88 |
"requestFailed": "请求失败",
|
| 89 |
"initFailed": "聊天页面初始化失败"
|
| 90 |
}
|
|
|
|
| 185 |
"batchNsfw": "开启 NSFW",
|
| 186 |
"batchNsfwDisable": "关闭 NSFW",
|
| 187 |
"batchRefresh": "刷新选中",
|
| 188 |
+
"batchDisable": "禁用选中",
|
| 189 |
+
"batchRestore": "恢复选中",
|
| 190 |
"batchDelete": "删除选中",
|
| 191 |
"colToken": "Token",
|
| 192 |
"colType": "账户类型",
|
|
|
|
| 269 |
"restoreConfirmBody": "确认恢复 {token}?<br><small style=\"color:var(--fg-muted)\">恢复后该账号将重新参与请求分配。</small>",
|
| 270 |
"restoringOne": "正在恢复账号…",
|
| 271 |
"restoreDone": "账号已恢复",
|
| 272 |
+
"batchDisableConfirmTitle": "批量禁用账号",
|
| 273 |
+
"batchDisableConfirmBody": "确认禁用选中的 <b>{n}</b> 个账户?<br><small style=\"color:var(--fg-muted)\">禁用后这些账号不会参与请求分配,但可随时恢复。</small>",
|
| 274 |
+
"disablingMany": "正在禁用 {n} 个账户…",
|
| 275 |
+
"disableManyDone": "禁用完成:成功 {ok} 个,失败 {fail} 个",
|
| 276 |
+
"batchRestoreConfirmTitle": "批量恢复账号",
|
| 277 |
+
"batchRestoreConfirmBody": "确认恢复选中的 <b>{n}</b> 个账户?<br><small style=\"color:var(--fg-muted)\">恢复后这些账号将重新参与请求分配。</small>",
|
| 278 |
+
"restoringMany": "正在恢复 {n} 个账户…",
|
| 279 |
+
"restoreManyDone": "恢复完成:成功 {ok} 个,失败 {fail} 个",
|
| 280 |
"nsfwConfirmTitle": "启用 NSFW",
|
| 281 |
"nsfwConfirmBody": "确认为选中的 <b>{n}</b> 个账户启用 NSFW?",
|
| 282 |
"nsfwEnablingOne": "正在启用 NSFW…",
|
|
|
|
| 443 |
},
|
| 444 |
"imageFormat": {
|
| 445 |
"label": "图片返回格式",
|
| 446 |
+
"desc": "grok_url / grok_md 默认直接返回 Grok CDN 地址;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。开启 Imagine Public 图片代理后,WebSocket 返回的 imagine-public 图片也会本地代理。"
|
| 447 |
+
},
|
| 448 |
+
"imaginePublicImageProxy": {
|
| 449 |
+
"label": "Imagine Public 图片代理",
|
| 450 |
+
"desc": "开启后将 WebSocket 返回的 imagine-public 图片下载到服务端,再通过本地 URL 代理分发;关闭时保持公开图片直返。"
|
| 451 |
},
|
| 452 |
"videoFormat": {
|
| 453 |
"label": "视频返回格式",
|
|
|
|
| 464 |
},
|
| 465 |
"basicInterval": {
|
| 466 |
"label": "Basic 周期(秒)",
|
| 467 |
+
"desc": "basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 86400s。"
|
| 468 |
},
|
| 469 |
"superInterval": {
|
| 470 |
"label": "Super 周期(秒)",
|
app/statics/js/webui/chat.js
CHANGED
|
@@ -2,7 +2,7 @@
|
|
| 2 |
const VERIFY_ENDPOINT = '/webui/api/verify';
|
| 3 |
const MODELS_ENDPOINT = '/webui/api/models';
|
| 4 |
const CHAT_ENDPOINT = '/webui/api/chat/completions';
|
| 5 |
-
const PREFERRED_MODEL = 'grok-4.20-0309';
|
| 6 |
const STORE_KEY = 'grok2api_webui_chat_sessions_v1';
|
| 7 |
const SIDEBAR_STORE_KEY = 'grok2api_webui_sidebar_collapsed_v1';
|
| 8 |
|
|
@@ -316,6 +316,64 @@
|
|
| 316 |
});
|
| 317 |
}
|
| 318 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 319 |
function extractTextContent(content) {
|
| 320 |
if (typeof content === 'string') return content;
|
| 321 |
if (!Array.isArray(content)) return '';
|
|
@@ -461,6 +519,7 @@
|
|
| 461 |
)).join(''));
|
| 462 |
}
|
| 463 |
card.innerHTML = parts.join('') || '<p></p>';
|
|
|
|
| 464 |
return;
|
| 465 |
}
|
| 466 |
|
|
@@ -501,6 +560,7 @@
|
|
| 501 |
|
| 502 |
if (role === 'assistant') {
|
| 503 |
card.innerHTML = renderRichMarkdown(content);
|
|
|
|
| 504 |
return;
|
| 505 |
}
|
| 506 |
card.textContent = content;
|
|
|
|
| 2 |
const VERIFY_ENDPOINT = '/webui/api/verify';
|
| 3 |
const MODELS_ENDPOINT = '/webui/api/models';
|
| 4 |
const CHAT_ENDPOINT = '/webui/api/chat/completions';
|
| 5 |
+
const PREFERRED_MODEL = 'grok-4.20-0309-non-reasoning';
|
| 6 |
const STORE_KEY = 'grok2api_webui_chat_sessions_v1';
|
| 7 |
const SIDEBAR_STORE_KEY = 'grok2api_webui_sidebar_collapsed_v1';
|
| 8 |
|
|
|
|
| 316 |
});
|
| 317 |
}
|
| 318 |
|
| 319 |
+
function isNativeGrokMediaUrl(value) {
|
| 320 |
+
try {
|
| 321 |
+
const url = new URL(value, window.location.origin);
|
| 322 |
+
return /(^|\.)grok\.com$/i.test(url.hostname);
|
| 323 |
+
} catch {
|
| 324 |
+
return false;
|
| 325 |
+
}
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
function showMediaProxyHint(media, type) {
|
| 329 |
+
if (!media || media.nextElementSibling?.classList?.contains('msg-media-error')) return;
|
| 330 |
+
const hint = document.createElement('div');
|
| 331 |
+
hint.className = 'msg-media-error';
|
| 332 |
+
if (type === 'image') {
|
| 333 |
+
hint.textContent = text(
|
| 334 |
+
'webui.chat.errors.imageProxyRequired',
|
| 335 |
+
'Image failed to load. Set APP Base URL and change image output format to local_url, local_md, or base64.'
|
| 336 |
+
);
|
| 337 |
+
} else {
|
| 338 |
+
hint.textContent = text(
|
| 339 |
+
'webui.chat.errors.videoProxyRequired',
|
| 340 |
+
'Video loading returned 403. Go to the admin page, set the APP Base URL, then change the video output format to local proxy mode (local_url or local_html) and retry.'
|
| 341 |
+
);
|
| 342 |
+
}
|
| 343 |
+
media.insertAdjacentElement('afterend', hint);
|
| 344 |
+
}
|
| 345 |
+
|
| 346 |
+
function clearMediaProxyHint(media) {
|
| 347 |
+
const hint = media && media.nextElementSibling;
|
| 348 |
+
if (hint?.classList?.contains('msg-media-error')) hint.remove();
|
| 349 |
+
}
|
| 350 |
+
|
| 351 |
+
function enhanceMediaElements(card) {
|
| 352 |
+
card.querySelectorAll('video').forEach((video) => {
|
| 353 |
+
if (video.dataset.proxyHintBound === '1') return;
|
| 354 |
+
video.dataset.proxyHintBound = '1';
|
| 355 |
+
const onVideoError = () => showMediaProxyHint(video, 'video');
|
| 356 |
+
video.addEventListener('error', onVideoError);
|
| 357 |
+
video.querySelectorAll('source').forEach((source) => {
|
| 358 |
+
source.addEventListener('error', onVideoError);
|
| 359 |
+
});
|
| 360 |
+
video.addEventListener('loadedmetadata', () => clearMediaProxyHint(video));
|
| 361 |
+
if (video.error) showMediaProxyHint(video, 'video');
|
| 362 |
+
});
|
| 363 |
+
|
| 364 |
+
card.querySelectorAll('img').forEach((img) => {
|
| 365 |
+
if (img.dataset.proxyHintBound === '1') return;
|
| 366 |
+
img.dataset.proxyHintBound = '1';
|
| 367 |
+
img.addEventListener('error', () => {
|
| 368 |
+
if (isNativeGrokMediaUrl(img.currentSrc || img.src)) showMediaProxyHint(img, 'image');
|
| 369 |
+
});
|
| 370 |
+
img.addEventListener('load', () => clearMediaProxyHint(img));
|
| 371 |
+
if (img.complete && img.naturalWidth === 0 && isNativeGrokMediaUrl(img.currentSrc || img.src)) {
|
| 372 |
+
showMediaProxyHint(img, 'image');
|
| 373 |
+
}
|
| 374 |
+
});
|
| 375 |
+
}
|
| 376 |
+
|
| 377 |
function extractTextContent(content) {
|
| 378 |
if (typeof content === 'string') return content;
|
| 379 |
if (!Array.isArray(content)) return '';
|
|
|
|
| 519 |
)).join(''));
|
| 520 |
}
|
| 521 |
card.innerHTML = parts.join('') || '<p></p>';
|
| 522 |
+
enhanceMediaElements(card);
|
| 523 |
return;
|
| 524 |
}
|
| 525 |
|
|
|
|
| 560 |
|
| 561 |
if (role === 'assistant') {
|
| 562 |
card.innerHTML = renderRichMarkdown(content);
|
| 563 |
+
enhanceMediaElements(card);
|
| 564 |
return;
|
| 565 |
}
|
| 566 |
card.textContent = content;
|
config.defaults.toml
CHANGED
|
@@ -51,6 +51,8 @@ custom_instruction = ""
|
|
| 51 |
# local_md — Markdown 内嵌本地代理 URL
|
| 52 |
# base64 — Markdown 内嵌 Base64 Data URI
|
| 53 |
image_format = "grok_url"
|
|
|
|
|
|
|
| 54 |
|
| 55 |
# 视频返回格式
|
| 56 |
# grok_url — 直接返回 Grok CDN URL
|
|
@@ -113,7 +115,7 @@ on_codes = "429,401,503"
|
|
| 113 |
[account.refresh]
|
| 114 |
# 总开关:true=配额刷新模式(主动探测,选号评分);false=自动重试模式(随机选号,零探测)
|
| 115 |
enabled = true
|
| 116 |
-
basic_interval_sec =
|
| 117 |
super_interval_sec = 7200 # super 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s
|
| 118 |
heavy_interval_sec = 7200 # heavy 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s
|
| 119 |
usage_concurrency = 50
|
|
|
|
| 51 |
# local_md — Markdown 内嵌本地代理 URL
|
| 52 |
# base64 — Markdown 内嵌 Base64 Data URI
|
| 53 |
image_format = "grok_url"
|
| 54 |
+
# Imagine WebSocket 返回的 imagine-public 图片默认直返;开启后下载到本地并返回本地代理 URL
|
| 55 |
+
imagine_public_image_proxy = false
|
| 56 |
|
| 57 |
# 视频返回格式
|
| 58 |
# grok_url — 直接返回 Grok CDN URL
|
|
|
|
| 115 |
[account.refresh]
|
| 116 |
# 总开关:true=配额刷新模式(主动探测,选号评分);false=自动重试模式(随机选号,零探测)
|
| 117 |
enabled = true
|
| 118 |
+
basic_interval_sec = 86400 # basic 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 86400s
|
| 119 |
super_interval_sec = 7200 # super 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s
|
| 120 |
heavy_interval_sec = 7200 # heavy 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 7200s
|
| 121 |
usage_concurrency = 50
|