opencode-ai commited on
Commit
b1e4ece
·
1 Parent(s): dddf47b

Latest grok2api code, keep old deps (fast build)

Browse files
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
- # Grok2API on Hugging Face Spaces
 
 
 
 
 
 
13
 
14
- A FastAPI-based Grok gateway that converts Grok web capabilities to OpenAI-compatible API.
15
 
16
- This space runs Grok2API with persistent storage using a Hugging Face Dataset for data persistence.
 
17
 
18
- ## Features
19
 
20
- - OpenAI compatible endpoints: `/v1/models`, `/v1/chat/completions`, `/v1/responses`, `/v1/messages`
21
- - Anthropic compatible endpoints: `/v1/messages`
22
- - Image generation, editing, and video generation capabilities
23
- - Admin dashboard and web UI
24
- - Multi-account pool with automatic maintenance
25
- - Persistent storage via HF Dataset mount
 
 
26
 
27
- ## Configuration
28
 
29
- The service uses:
30
- - Server port: 7860 (HF Spaces default)
31
- - Data directory: `/data` (mounted from HF Dataset)
32
- - Logs directory: `/data/logs`
33
 
34
- ## Usage
 
 
35
 
36
- Once the space is running, you can access the API at:
37
- - Base URL: `https://your-username-grok2api-hf-clean.hf.space`
38
- - API endpoints: `https://your-username-grok2api-hf-clean.hf.space/v1/*`
 
 
 
39
 
40
- Authentication:
41
- - Set `app.api_key` in the runtime config (`/data/config.toml`) for API key protection
42
- - Admin interface: `/admin/login` (default key: `grok2api`)
43
- - Web UI: `/webui/login` (if enabled)
 
 
44
 
45
- ## Persistent Storage
 
 
 
 
 
46
 
47
- This space uses a Hugging Face Dataset (`hermesinho/grok2api-data`) mounted at `/data` to persist:
48
- - Account information (SQLite database)
49
- - Configuration files
50
- - Cached media files
51
- - Logs
 
 
 
52
 
53
- ## Environment Variables
 
 
54
 
55
- Override default settings by editing `/data/config.toml` or setting environment variables:
 
 
 
 
 
56
 
57
- - `GROK_APP_API_KEY` - API key for `/v1/*` endpoints
58
- - `GROK_APP_APP_KEY` - Key for `/admin/*` endpoints (default: grok2api)
59
- - `GROK_LOG_LEVEL` - Logging level (default: INFO)
 
 
60
 
61
- For more configuration options, see the [original documentation](https://github.com/chenyme/grok2api).
 
 
62
 
63
- ---Last updated: 2026-04-26T16:53:11Z
64
- # Test
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <img alt="Grok2API" src="https://github.com/user-attachments/assets/037a0a6e-7986-41cc-b4af-04df612ee886" />
 
 
 
 
 
 
 
 
 
2
 
3
+ [![Python](https://img.shields.io/badge/python-3.13%2B-3776AB?logo=python&logoColor=white)](https://www.python.org/)
4
+ [![FastAPI](https://img.shields.io/badge/FastAPI-0.119%2B-009688?logo=fastapi&logoColor=white)](https://fastapi.tiangolo.com/)
5
+ [![Version](https://img.shields.io/badge/version-2.0.4.rc2-111827)](pyproject.toml)
6
+ [![License](https://img.shields.io/badge/license-MIT-16a34a)](LICENSE)
7
+ [![English](https://img.shields.io/badge/English-2563EB?logo=bookstack&logoColor=white)](docs/README.en.md)
8
+ [![DeepWiki](https://deepwiki.com/badge.svg)](https://deepwiki.com/chenyme/grok2api)
9
+ [![项目文档](https://img.shields.io/badge/项目文档-0F766E?logo=readthedocs&logoColor=white)](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
+ [![Deploy with Vercel](https://vercel.com/button)](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
+ [![Deploy to Render](https://render.com/images/deploy-to-render-button.svg)](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
+ [![Star History Chart](https://api.star-history.com/svg?repos=Chenyme/grok2api&type=Timeline)](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 20 60 8 — — window: 72000 / 36000 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 the three values
11
- (20 / 50 / 150) are mutually exclusive across pool types.
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(20, 20, 72_000), # 20 queries / 20 h
45
- fast=_w(60, 60, 72_000), # 60 queries / 20 h
46
- expert=_w(8, 8, 36_000), # 8 queries / 10 h
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((0, 1, 2)),
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 -> auto / fast / expert
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
- patches[_MODE_KEYS[mode_id]] = windows[mode_id].to_dict()
 
 
 
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
- quota_patch[mode_key] = window.to_dict()
 
 
 
 
 
 
 
 
 
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 3600010 h)
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", 36_000),
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 8640024 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.BASIC, Capability.CHAT, True, "Grok 4.20 0309"),
18
- ModelSpec("grok-4.20-0309-reasoning", ModeId.EXPERT, Tier.BASIC, 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,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.BASIC, Capability.CHAT, True, "Grok 4.20 Auto", prefer_best=True),
32
- ModelSpec("grok-4.20-expert", ModeId.EXPERT, Tier.BASIC, 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,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
- non-HEAVY → try heavy first, then super, then basic
 
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", 36_000),
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: 72_000, # auto — 20 h (basic) / 2 h (super/heavy, real value overrides)
29
- 1: 72_000, # fast — 20 h (basic)
30
- 2: 36_000, # expert — 10 h (basic)
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(body: dict) -> dict | None:
 
 
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 72_000,
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(body)
 
 
 
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.platform.logging.logger import logger
 
 
 
 
 
 
 
 
 
 
 
 
 
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 AccountPatch, AccountUpsert, ListAccountsQuery
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"![image]({url})"
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 == "local_url":
258
  return local_url
259
- return f"![image]({local_url})" # 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"![image]({url})"
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"![image]({local_url})" # 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 _quota_sync, _fail_sync, _feedback_kind
 
 
 
 
 
 
 
 
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"![image]({url})")
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, all pools)
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. All account pools
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
- _progress_reason("图片", aggregate, completed=completed, total=n),
 
 
 
 
 
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 = 5
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(token: str, image_input: str, index: int) -> str:
 
 
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 resolve_uploaded_asset_reference(token, file_id, file_uri)
 
 
 
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(token: str, image_inputs: list[str]) -> list[str]:
 
 
606
  """Upload edit references concurrently and preserve caller order."""
607
- results: list[str | None] = [None] * len(image_inputs)
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
- acct = await _acct_dir.reserve(
914
- pool_candidates = spec.pool_candidates(),
915
- mode_id = int(spec.mode_id),
916
- now_s_override = now_s(),
917
- )
918
- if acct is None:
919
- raise RateLimitError("No available accounts for image generation")
920
 
921
- token = acct.token
922
- adapter = StreamAdapter()
923
- success = False
924
- fail_exc: BaseException | None = None
925
- try:
926
- async for line in _stream_lite_generate(
927
- token,
928
- prompt,
929
- spec.mode_id,
930
- timeout_s=timeout_s,
931
- ):
932
- ev_type, data = classify_line(line)
933
- if ev_type == "done":
934
- break
935
- if ev_type != "data" or not data:
936
- continue
937
- for ev in adapter.feed(data):
938
- if ev.kind == "image_progress":
939
- if progress_cb is not None:
940
- try:
941
- await progress_cb(_clamp_progress(int(ev.content or "0")))
942
- except ValueError:
943
- pass
944
- if ev.kind == "image" and ev.content:
945
- if progress_cb is not None:
946
- await progress_cb(100)
947
- image = await _resolve_image_output(
948
- token=token,
949
- url=ev.content,
950
- response_format=response_format,
951
- )
952
- success = True
953
- return image
954
- raise UpstreamError("Image generation returned no images")
955
- except BaseException as exc:
956
- fail_exc = exc
957
- raise
958
- finally:
959
- await _acct_dir.release(acct)
960
- kind = FeedbackKind.SUCCESS if success else _feedback_kind(fail_exc) if fail_exc else FeedbackKind.SERVER_ERROR
961
- await _acct_dir.feedback(token, kind, int(spec.mode_id))
962
- if success:
963
- asyncio.create_task(_quota_sync(token, int(spec.mode_id)))
964
- else:
965
- asyncio.create_task(_fail_sync(token, int(spec.mode_id), fail_exc))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- image_references = await _prepare_edit_references(token, image_inputs)
1032
- if not image_references:
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=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=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
- _progress_reason("图片", aggregate, completed=completed, total=n),
 
 
 
 
 
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=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"![image]({url})")
231
  if fmt == "url" and not _app_url():
232
  return _ImageOutput(api_value=url, markdown_value=f"![image]({url})")
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
- candidates = {_POOL_ID_TO_NAME[pool_id] for pool_id in spec.pool_candidates()}
52
- return bool(candidates & pools)
 
 
 
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[:5]
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 = "grok-3"
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
- results: list[_VideoReference | None] = [None] * len(input_references)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
438
 
439
- async def _runner(index: int, ref: dict[str, Any]) -> None:
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
- return [r for r in results if r is not None]
 
 
 
 
 
 
 
 
 
 
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("Video generation returned no final video URL")
 
 
 
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
- return b"".join(chunks), (content_type or "video/mp4")
 
 
 
 
 
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.warning("video download failed: fallback_to=upstream_url error={}", exc)
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(str(exc))
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[:5]]
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, _progress_reason(progress)
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 super/basic pools try super first, then basic, then heavy.
36
  from app.control.model.enums import ModeId
37
 
38
  ts = now_s()
39
- acct = await _acct_dir.reserve(pool_candidates=(1, 0, 2), mode_id=int(ModeId.AUTO), now_s_override=ts)
 
 
 
 
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 地址,客户端需具备 CDN 访问能力;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。',
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 冷却。默认 36000s。', descKey: 'config.schema.fields.basicInterval.desc' },
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: 36000s." },
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 36000s."
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: 36000s." },
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 : 36000s." },
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 クールダウンに使われます。既定値は 36000s です。" },
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 地址,客户端需具备 CDN 访问能力;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。"
 
 
 
 
435
  },
436
  "videoFormat": {
437
  "label": "视频返回格式",
@@ -448,7 +464,7 @@
448
  },
449
  "basicInterval": {
450
  "label": "Basic 周期(秒)",
451
- "desc": "basic 号池共用周期:quota 模式下用于后台刷新,random 模式下用于 429 冷却。默认 36000s。"
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 = 36000 # basic 号池周期(秒):quota 模式用于后台刷新,random 模式用于 429 冷却;默认 36000s
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