cxk commited on
Commit ·
d93bff5
1
Parent(s): db809d9
feat(api): 集成ChatGPT服务并支持图像生成功能
Browse files- 添加ChatGPTService类用于处理图像生成请求
- 实现图像生成相关API端点(/v1/images/generations, /v1/chat/completions, /v1/responses)
- 实现图像生成结果的处理和返回
- 添加图像服务模块处理底层API交互
- 新增测试文件验证图像生成功能
- README.md +18 -1
- services/account_service.py +46 -9
- services/api.py +41 -64
- services/backend_service.py +0 -82
- services/chat_image_service.py +0 -174
- services/chatgpt_service.py +143 -0
- services/image_service.py +43 -49
- services/proof_of_work.py +5 -27
- services/test_image.py +0 -98
- services/utils.py +164 -0
- test/test_completions.py +12 -0
- test/test_generations.py +12 -0
- test/test_image.py +16 -0
- test/utils.py +47 -0
- web/next.config.ts +19 -0
- web/package.json +1 -6
README.md
CHANGED
|
@@ -6,7 +6,10 @@ ChatGPT 图片生成代理与账号池管理面板,提供账号维护、额度
|
|
| 6 |
|
| 7 |
## 功能
|
| 8 |
|
| 9 |
-
-
|
|
|
|
|
|
|
|
|
|
| 10 |
- 自动刷新账号邮箱、类型、图片额度、恢复时间
|
| 11 |
- 轮询可用账号进行图片生成
|
| 12 |
- 失效 Token 自动剔除
|
|
@@ -15,9 +18,15 @@ ChatGPT 图片生成代理与账号池管理面板,提供账号维护、额度
|
|
| 15 |
> 目前仅实现了生图效果,编辑图片以及gpt-image-2模型尚未实现,需要后续更新。
|
| 16 |
|
| 17 |
生图界面:
|
|
|
|
| 18 |

|
| 19 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
号池管理:
|
|
|
|
| 21 |

|
| 22 |
|
| 23 |
## 接口
|
|
@@ -34,6 +43,14 @@ Authorization: Bearer <auth-key>
|
|
| 34 |
POST /v1/images/generations
|
| 35 |
```
|
| 36 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
请求体示例:
|
| 38 |
|
| 39 |
```json
|
|
|
|
| 6 |
|
| 7 |
## 功能
|
| 8 |
|
| 9 |
+
- 兼容 OpenAI `Chat Completions` 图片响应
|
| 10 |
+
- 兼容 OpenAI `Responses API` 图片生成接口
|
| 11 |
+
- 支持导入 CPA 格式文件
|
| 12 |
+
- 支持多种方式导入 `access_token`
|
| 13 |
- 自动刷新账号邮箱、类型、图片额度、恢复时间
|
| 14 |
- 轮询可用账号进行图片生成
|
| 15 |
- 失效 Token 自动剔除
|
|
|
|
| 18 |
> 目前仅实现了生图效果,编辑图片以及gpt-image-2模型尚未实现,需要后续更新。
|
| 19 |
|
| 20 |
生图界面:
|
| 21 |
+
|
| 22 |

|
| 23 |
|
| 24 |
+
Chery Studio 中使用:
|
| 25 |
+
|
| 26 |
+

|
| 27 |
+
|
| 28 |
号池管理:
|
| 29 |
+
|
| 30 |

|
| 31 |
|
| 32 |
## 接口
|
|
|
|
| 43 |
POST /v1/images/generations
|
| 44 |
```
|
| 45 |
|
| 46 |
+
```http
|
| 47 |
+
POST /v1/chat/completions
|
| 48 |
+
```
|
| 49 |
+
|
| 50 |
+
```http
|
| 51 |
+
POST /v1/responses
|
| 52 |
+
```
|
| 53 |
+
|
| 54 |
请求体示例:
|
| 55 |
|
| 56 |
```json
|
services/account_service.py
CHANGED
|
@@ -221,22 +221,59 @@ class AccountService:
|
|
| 221 |
with self._lock:
|
| 222 |
return [token for item in self._accounts if (token := self._clean_token(item.get("access_token")))]
|
| 223 |
|
| 224 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 225 |
with self._lock:
|
| 226 |
-
|
| 227 |
-
tokens = [
|
| 228 |
-
token
|
| 229 |
-
for item in self._accounts
|
| 230 |
-
if self._is_image_account_available(item)
|
| 231 |
-
and (token := self._clean_token(item.get("access_token")))
|
| 232 |
-
and token not in excluded
|
| 233 |
-
]
|
| 234 |
if not tokens:
|
| 235 |
raise RuntimeError(f"No available tokens found in {self.store_file}")
|
| 236 |
access_token = tokens[self._index % len(tokens)]
|
| 237 |
self._index += 1
|
| 238 |
return access_token
|
| 239 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 240 |
def get_account(self, access_token: str) -> dict | None:
|
| 241 |
access_token = self._clean_token(access_token)
|
| 242 |
if not access_token:
|
|
|
|
| 221 |
with self._lock:
|
| 222 |
return [token for item in self._accounts if (token := self._clean_token(item.get("access_token")))]
|
| 223 |
|
| 224 |
+
def _list_available_candidate_tokens(self, excluded_tokens: set[str] | None = None) -> list[str]:
|
| 225 |
+
excluded = {self._clean_token(token) for token in (excluded_tokens or set()) if self._clean_token(token)}
|
| 226 |
+
return [
|
| 227 |
+
token
|
| 228 |
+
for item in self._accounts
|
| 229 |
+
if self._is_image_account_available(item)
|
| 230 |
+
and (token := self._clean_token(item.get("access_token")))
|
| 231 |
+
and token not in excluded
|
| 232 |
+
]
|
| 233 |
+
|
| 234 |
+
def _pick_next_candidate_token(self, excluded_tokens: set[str] | None = None) -> str:
|
| 235 |
with self._lock:
|
| 236 |
+
tokens = self._list_available_candidate_tokens(excluded_tokens)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
if not tokens:
|
| 238 |
raise RuntimeError(f"No available tokens found in {self.store_file}")
|
| 239 |
access_token = tokens[self._index % len(tokens)]
|
| 240 |
self._index += 1
|
| 241 |
return access_token
|
| 242 |
|
| 243 |
+
def refresh_account_state(self, access_token: str) -> dict | None:
|
| 244 |
+
try:
|
| 245 |
+
remote_info = self.fetch_remote_info(access_token)
|
| 246 |
+
except Exception as exc:
|
| 247 |
+
message = str(exc)
|
| 248 |
+
print(f"[account-available] refresh token={access_token[:12]}... fail {message}")
|
| 249 |
+
if "/backend-api/me failed: HTTP 401" in message:
|
| 250 |
+
return self.update_account(
|
| 251 |
+
access_token,
|
| 252 |
+
{
|
| 253 |
+
"status": "异常",
|
| 254 |
+
"quota": 0,
|
| 255 |
+
},
|
| 256 |
+
)
|
| 257 |
+
return None
|
| 258 |
+
return self.update_account(access_token, remote_info)
|
| 259 |
+
|
| 260 |
+
def get_available_access_token(self) -> str:
|
| 261 |
+
attempted_tokens: set[str] = set()
|
| 262 |
+
while True:
|
| 263 |
+
access_token = self._pick_next_candidate_token(excluded_tokens=attempted_tokens)
|
| 264 |
+
attempted_tokens.add(access_token)
|
| 265 |
+
account = self.refresh_account_state(access_token)
|
| 266 |
+
if self._is_image_account_available(account or {}):
|
| 267 |
+
return access_token
|
| 268 |
+
print(
|
| 269 |
+
f"[account-available] skip token={access_token[:12]}... "
|
| 270 |
+
f"quota={account.get('quota') if account else 'unknown'} "
|
| 271 |
+
f"status={account.get('status') if account else 'unknown'}"
|
| 272 |
+
)
|
| 273 |
+
|
| 274 |
+
def next_token(self) -> str:
|
| 275 |
+
return self.get_available_access_token()
|
| 276 |
+
|
| 277 |
def get_account(self, access_token: str) -> dict | None:
|
| 278 |
access_token = self._clean_token(access_token)
|
| 279 |
if not access_token:
|
services/api.py
CHANGED
|
@@ -8,16 +8,14 @@ from fastapi import APIRouter, FastAPI, Header, HTTPException
|
|
| 8 |
from fastapi.concurrency import run_in_threadpool
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.responses import FileResponse
|
| 11 |
-
from pydantic import BaseModel, Field
|
| 12 |
|
| 13 |
from services.account_service import account_service
|
|
|
|
| 14 |
from services.config import config
|
| 15 |
-
from services.backend_service import BackendService
|
| 16 |
-
from services.chat_image_service import ChatImageService
|
| 17 |
from services.image_service import ImageGenerationError
|
| 18 |
from services.version import get_app_version
|
| 19 |
|
| 20 |
-
|
| 21 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 22 |
WEB_DIST_DIR = BASE_DIR / "web_dist"
|
| 23 |
|
|
@@ -49,6 +47,27 @@ class AccountUpdateRequest(BaseModel):
|
|
| 49 |
quota: int | None = None
|
| 50 |
|
| 51 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 52 |
def build_model_item(model_id: str) -> dict[str, object]:
|
| 53 |
return {
|
| 54 |
"id": model_id,
|
|
@@ -98,11 +117,7 @@ def resolve_web_asset(requested_path: str) -> Path | None:
|
|
| 98 |
candidates = [WEB_DIST_DIR / "index.html"]
|
| 99 |
else:
|
| 100 |
relative_path = Path(clean_path)
|
| 101 |
-
candidates = [
|
| 102 |
-
WEB_DIST_DIR / relative_path,
|
| 103 |
-
WEB_DIST_DIR / relative_path / "index.html",
|
| 104 |
-
WEB_DIST_DIR / f"{clean_path}.html",
|
| 105 |
-
]
|
| 106 |
|
| 107 |
for candidate in candidates:
|
| 108 |
try:
|
|
@@ -116,8 +131,7 @@ def resolve_web_asset(requested_path: str) -> Path | None:
|
|
| 116 |
|
| 117 |
|
| 118 |
def create_app() -> FastAPI:
|
| 119 |
-
|
| 120 |
-
chat_image_service = ChatImageService(service)
|
| 121 |
app_version = get_app_version()
|
| 122 |
|
| 123 |
@asynccontextmanager
|
|
@@ -142,13 +156,7 @@ def create_app() -> FastAPI:
|
|
| 142 |
|
| 143 |
@router.get("/v1/models")
|
| 144 |
async def list_models():
|
| 145 |
-
return {
|
| 146 |
-
"object": "list",
|
| 147 |
-
"data": [
|
| 148 |
-
build_model_item("gpt-image-1"),
|
| 149 |
-
build_model_item("gpt-image-2"),
|
| 150 |
-
],
|
| 151 |
-
}
|
| 152 |
|
| 153 |
@router.post("/auth/login")
|
| 154 |
async def login(authorization: str | None = Header(default=None)):
|
|
@@ -165,28 +173,17 @@ def create_app() -> FastAPI:
|
|
| 165 |
return {"items": account_service.list_accounts()}
|
| 166 |
|
| 167 |
@router.post("/api/accounts")
|
| 168 |
-
async def create_accounts(
|
| 169 |
-
body: AccountCreateRequest,
|
| 170 |
-
authorization: str | None = Header(default=None),
|
| 171 |
-
):
|
| 172 |
require_auth_key(authorization)
|
| 173 |
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 174 |
if not tokens:
|
| 175 |
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 176 |
result = account_service.add_accounts(tokens)
|
| 177 |
refresh_result = account_service.refresh_accounts(tokens)
|
| 178 |
-
return {
|
| 179 |
-
**result,
|
| 180 |
-
"refreshed": refresh_result.get("refreshed", 0),
|
| 181 |
-
"errors": refresh_result.get("errors", []),
|
| 182 |
-
"items": refresh_result.get("items", result.get("items", [])),
|
| 183 |
-
}
|
| 184 |
|
| 185 |
@router.delete("/api/accounts")
|
| 186 |
-
async def delete_accounts(
|
| 187 |
-
body: AccountDeleteRequest,
|
| 188 |
-
authorization: str | None = Header(default=None),
|
| 189 |
-
):
|
| 190 |
require_auth_key(authorization)
|
| 191 |
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 192 |
if not tokens:
|
|
@@ -194,10 +191,7 @@ def create_app() -> FastAPI:
|
|
| 194 |
return account_service.delete_accounts(tokens)
|
| 195 |
|
| 196 |
@router.post("/api/accounts/refresh")
|
| 197 |
-
async def refresh_accounts(
|
| 198 |
-
body: AccountRefreshRequest,
|
| 199 |
-
authorization: str | None = Header(default=None),
|
| 200 |
-
):
|
| 201 |
require_auth_key(authorization)
|
| 202 |
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
| 203 |
if not access_tokens:
|
|
@@ -207,24 +201,13 @@ def create_app() -> FastAPI:
|
|
| 207 |
return account_service.refresh_accounts(access_tokens)
|
| 208 |
|
| 209 |
@router.post("/api/accounts/update")
|
| 210 |
-
async def update_account(
|
| 211 |
-
body: AccountUpdateRequest,
|
| 212 |
-
authorization: str | None = Header(default=None),
|
| 213 |
-
):
|
| 214 |
require_auth_key(authorization)
|
| 215 |
access_token = str(body.access_token or "").strip()
|
| 216 |
if not access_token:
|
| 217 |
raise HTTPException(status_code=400, detail={"error": "access_token is required"})
|
| 218 |
|
| 219 |
-
updates = {
|
| 220 |
-
key: value
|
| 221 |
-
for key, value in {
|
| 222 |
-
"type": body.type,
|
| 223 |
-
"status": body.status,
|
| 224 |
-
"quota": body.quota,
|
| 225 |
-
}.items()
|
| 226 |
-
if value is not None
|
| 227 |
-
}
|
| 228 |
if not updates:
|
| 229 |
raise HTTPException(status_code=400, detail={"error": "no updates provided"})
|
| 230 |
|
|
@@ -234,28 +217,22 @@ def create_app() -> FastAPI:
|
|
| 234 |
return {"item": account, "items": account_service.list_accounts()}
|
| 235 |
|
| 236 |
@router.post("/v1/images/generations")
|
| 237 |
-
async def generate_images(
|
| 238 |
-
body: ImageGenerationRequest,
|
| 239 |
-
authorization: str | None = Header(default=None),
|
| 240 |
-
):
|
| 241 |
require_auth_key(authorization)
|
| 242 |
try:
|
| 243 |
-
return await run_in_threadpool(
|
| 244 |
-
service.generate_with_pool,
|
| 245 |
-
body.prompt,
|
| 246 |
-
body.model,
|
| 247 |
-
body.n,
|
| 248 |
-
)
|
| 249 |
except ImageGenerationError as exc:
|
| 250 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 251 |
|
| 252 |
@router.post("/v1/chat/completions")
|
| 253 |
-
async def create_chat_completion(
|
| 254 |
-
|
| 255 |
-
|
| 256 |
-
|
|
|
|
|
|
|
| 257 |
require_auth_key(authorization)
|
| 258 |
-
return await run_in_threadpool(
|
| 259 |
|
| 260 |
app.include_router(router)
|
| 261 |
|
|
|
|
| 8 |
from fastapi.concurrency import run_in_threadpool
|
| 9 |
from fastapi.middleware.cors import CORSMiddleware
|
| 10 |
from fastapi.responses import FileResponse
|
| 11 |
+
from pydantic import BaseModel, ConfigDict, Field
|
| 12 |
|
| 13 |
from services.account_service import account_service
|
| 14 |
+
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
|
|
|
|
|
|
| 16 |
from services.image_service import ImageGenerationError
|
| 17 |
from services.version import get_app_version
|
| 18 |
|
|
|
|
| 19 |
BASE_DIR = Path(__file__).resolve().parents[1]
|
| 20 |
WEB_DIST_DIR = BASE_DIR / "web_dist"
|
| 21 |
|
|
|
|
| 47 |
quota: int | None = None
|
| 48 |
|
| 49 |
|
| 50 |
+
class ChatCompletionRequest(BaseModel):
|
| 51 |
+
model_config = ConfigDict(extra="allow")
|
| 52 |
+
|
| 53 |
+
model: str | None = None
|
| 54 |
+
prompt: str | None = None
|
| 55 |
+
n: int | None = None
|
| 56 |
+
stream: bool | None = None
|
| 57 |
+
modalities: list[str] | None = None
|
| 58 |
+
messages: list[dict[str, object]] | None = None
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
class ResponseCreateRequest(BaseModel):
|
| 62 |
+
model_config = ConfigDict(extra="allow")
|
| 63 |
+
|
| 64 |
+
model: str | None = None
|
| 65 |
+
input: object | None = None
|
| 66 |
+
tools: list[dict[str, object]] | None = None
|
| 67 |
+
tool_choice: object | None = None
|
| 68 |
+
stream: bool | None = None
|
| 69 |
+
|
| 70 |
+
|
| 71 |
def build_model_item(model_id: str) -> dict[str, object]:
|
| 72 |
return {
|
| 73 |
"id": model_id,
|
|
|
|
| 117 |
candidates = [WEB_DIST_DIR / "index.html"]
|
| 118 |
else:
|
| 119 |
relative_path = Path(clean_path)
|
| 120 |
+
candidates = [WEB_DIST_DIR / relative_path, WEB_DIST_DIR / relative_path / "index.html", WEB_DIST_DIR / f"{clean_path}.html"]
|
|
|
|
|
|
|
|
|
|
|
|
|
| 121 |
|
| 122 |
for candidate in candidates:
|
| 123 |
try:
|
|
|
|
| 131 |
|
| 132 |
|
| 133 |
def create_app() -> FastAPI:
|
| 134 |
+
chatgpt_service = ChatGPTService(account_service)
|
|
|
|
| 135 |
app_version = get_app_version()
|
| 136 |
|
| 137 |
@asynccontextmanager
|
|
|
|
| 156 |
|
| 157 |
@router.get("/v1/models")
|
| 158 |
async def list_models():
|
| 159 |
+
return {"object": "list", "data": [build_model_item("gpt-image-1"), build_model_item("gpt-image-2")]}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
|
| 161 |
@router.post("/auth/login")
|
| 162 |
async def login(authorization: str | None = Header(default=None)):
|
|
|
|
| 173 |
return {"items": account_service.list_accounts()}
|
| 174 |
|
| 175 |
@router.post("/api/accounts")
|
| 176 |
+
async def create_accounts(body: AccountCreateRequest, authorization: str | None = Header(default=None)):
|
|
|
|
|
|
|
|
|
|
| 177 |
require_auth_key(authorization)
|
| 178 |
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 179 |
if not tokens:
|
| 180 |
raise HTTPException(status_code=400, detail={"error": "tokens is required"})
|
| 181 |
result = account_service.add_accounts(tokens)
|
| 182 |
refresh_result = account_service.refresh_accounts(tokens)
|
| 183 |
+
return {**result, "refreshed": refresh_result.get("refreshed", 0), "errors": refresh_result.get("errors", []), "items": refresh_result.get("items", result.get("items", []))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 184 |
|
| 185 |
@router.delete("/api/accounts")
|
| 186 |
+
async def delete_accounts(body: AccountDeleteRequest, authorization: str | None = Header(default=None)):
|
|
|
|
|
|
|
|
|
|
| 187 |
require_auth_key(authorization)
|
| 188 |
tokens = [str(token or "").strip() for token in body.tokens if str(token or "").strip()]
|
| 189 |
if not tokens:
|
|
|
|
| 191 |
return account_service.delete_accounts(tokens)
|
| 192 |
|
| 193 |
@router.post("/api/accounts/refresh")
|
| 194 |
+
async def refresh_accounts(body: AccountRefreshRequest, authorization: str | None = Header(default=None)):
|
|
|
|
|
|
|
|
|
|
| 195 |
require_auth_key(authorization)
|
| 196 |
access_tokens = [str(token or "").strip() for token in body.access_tokens if str(token or "").strip()]
|
| 197 |
if not access_tokens:
|
|
|
|
| 201 |
return account_service.refresh_accounts(access_tokens)
|
| 202 |
|
| 203 |
@router.post("/api/accounts/update")
|
| 204 |
+
async def update_account(body: AccountUpdateRequest, authorization: str | None = Header(default=None)):
|
|
|
|
|
|
|
|
|
|
| 205 |
require_auth_key(authorization)
|
| 206 |
access_token = str(body.access_token or "").strip()
|
| 207 |
if not access_token:
|
| 208 |
raise HTTPException(status_code=400, detail={"error": "access_token is required"})
|
| 209 |
|
| 210 |
+
updates = {key: value for key, value in {"type": body.type, "status": body.status, "quota": body.quota}.items() if value is not None}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 211 |
if not updates:
|
| 212 |
raise HTTPException(status_code=400, detail={"error": "no updates provided"})
|
| 213 |
|
|
|
|
| 217 |
return {"item": account, "items": account_service.list_accounts()}
|
| 218 |
|
| 219 |
@router.post("/v1/images/generations")
|
| 220 |
+
async def generate_images(body: ImageGenerationRequest, authorization: str | None = Header(default=None)):
|
|
|
|
|
|
|
|
|
|
| 221 |
require_auth_key(authorization)
|
| 222 |
try:
|
| 223 |
+
return await run_in_threadpool(chatgpt_service.generate_with_pool, body.prompt, body.model, body.n)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 224 |
except ImageGenerationError as exc:
|
| 225 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 226 |
|
| 227 |
@router.post("/v1/chat/completions")
|
| 228 |
+
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
| 229 |
+
require_auth_key(authorization)
|
| 230 |
+
return await run_in_threadpool(chatgpt_service.create_image_completion, body.model_dump(mode="python"))
|
| 231 |
+
|
| 232 |
+
@router.post("/v1/responses")
|
| 233 |
+
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
| 234 |
require_auth_key(authorization)
|
| 235 |
+
return await run_in_threadpool(chatgpt_service.create_response, body.model_dump(mode="python"))
|
| 236 |
|
| 237 |
app.include_router(router)
|
| 238 |
|
services/backend_service.py
DELETED
|
@@ -1,82 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
from fastapi import HTTPException
|
| 4 |
-
|
| 5 |
-
from services.account_service import AccountService
|
| 6 |
-
from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
|
| 7 |
-
|
| 8 |
-
|
| 9 |
-
class BackendService:
|
| 10 |
-
def __init__(self, account_service: AccountService):
|
| 11 |
-
self.account_service = account_service
|
| 12 |
-
|
| 13 |
-
@staticmethod
|
| 14 |
-
def _is_account_ready_for_image(account: dict | None) -> bool:
|
| 15 |
-
if not isinstance(account, dict):
|
| 16 |
-
return False
|
| 17 |
-
if account.get("status") in {"禁用", "异常"}:
|
| 18 |
-
return False
|
| 19 |
-
return int(account.get("quota") or 0) > 0
|
| 20 |
-
|
| 21 |
-
def _refresh_request_token(self, access_token: str) -> dict | None:
|
| 22 |
-
try:
|
| 23 |
-
remote_info = self.account_service.fetch_remote_info(access_token)
|
| 24 |
-
except Exception as exc:
|
| 25 |
-
message = str(exc)
|
| 26 |
-
print(f"[image-generate] refresh token={access_token[:12]}... fail {message}")
|
| 27 |
-
if "/backend-api/me failed: HTTP 401" in message:
|
| 28 |
-
return self.account_service.update_account(
|
| 29 |
-
access_token,
|
| 30 |
-
{
|
| 31 |
-
"status": "异常",
|
| 32 |
-
"quota": 0,
|
| 33 |
-
},
|
| 34 |
-
)
|
| 35 |
-
return None
|
| 36 |
-
return self.account_service.update_account(access_token, remote_info)
|
| 37 |
-
|
| 38 |
-
def resolve_request_token(self, excluded_tokens: set[str] | None = None) -> str:
|
| 39 |
-
try:
|
| 40 |
-
return self.account_service.next_token(excluded_tokens=excluded_tokens)
|
| 41 |
-
except RuntimeError as exc:
|
| 42 |
-
raise HTTPException(status_code=503, detail={"error": str(exc)}) from exc
|
| 43 |
-
|
| 44 |
-
def generate_with_pool(self, prompt: str, model: str, n: int):
|
| 45 |
-
attempted_tokens: set[str] = set()
|
| 46 |
-
|
| 47 |
-
while True:
|
| 48 |
-
try:
|
| 49 |
-
request_token = self.resolve_request_token(excluded_tokens=attempted_tokens)
|
| 50 |
-
except HTTPException:
|
| 51 |
-
raise
|
| 52 |
-
|
| 53 |
-
attempted_tokens.add(request_token)
|
| 54 |
-
refreshed_account = self._refresh_request_token(request_token)
|
| 55 |
-
if not self._is_account_ready_for_image(refreshed_account):
|
| 56 |
-
print(
|
| 57 |
-
f"[image-generate] skip token={request_token[:12]}... "
|
| 58 |
-
f"quota={refreshed_account.get('quota') if refreshed_account else 'unknown'} "
|
| 59 |
-
f"status={refreshed_account.get('status') if refreshed_account else 'unknown'}"
|
| 60 |
-
)
|
| 61 |
-
continue
|
| 62 |
-
|
| 63 |
-
print(f"[image-generate] start pooled token={request_token[:12]}... model={model} n={n}")
|
| 64 |
-
try:
|
| 65 |
-
result = generate_image_result(request_token, prompt, model, n)
|
| 66 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 67 |
-
print(
|
| 68 |
-
f"[image-generate] success pooled token={request_token[:12]}... "
|
| 69 |
-
f"quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
|
| 70 |
-
)
|
| 71 |
-
return result
|
| 72 |
-
except ImageGenerationError as exc:
|
| 73 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 74 |
-
print(
|
| 75 |
-
f"[image-generate] fail pooled token={request_token[:12]}... "
|
| 76 |
-
f"error={exc} quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
|
| 77 |
-
)
|
| 78 |
-
if is_token_invalid_error(str(exc)):
|
| 79 |
-
self.account_service.remove_token(request_token)
|
| 80 |
-
print(f"[image-generate] remove invalid token={request_token[:12]}...")
|
| 81 |
-
continue
|
| 82 |
-
raise
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/chat_image_service.py
DELETED
|
@@ -1,174 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import time
|
| 4 |
-
import uuid
|
| 5 |
-
|
| 6 |
-
from fastapi import HTTPException
|
| 7 |
-
|
| 8 |
-
from services.backend_service import BackendService
|
| 9 |
-
from services.image_service import ImageGenerationError
|
| 10 |
-
|
| 11 |
-
|
| 12 |
-
IMAGE_MODELS = {"gpt-image-1", "gpt-image-2"}
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
class ChatImageService:
|
| 16 |
-
def __init__(self, backend_service: BackendService):
|
| 17 |
-
self.backend_service = backend_service
|
| 18 |
-
|
| 19 |
-
def is_image_chat_request(self, body: dict[str, object]) -> bool:
|
| 20 |
-
model = str(body.get("model") or "").strip()
|
| 21 |
-
modalities = body.get("modalities")
|
| 22 |
-
if model in IMAGE_MODELS:
|
| 23 |
-
return True
|
| 24 |
-
if isinstance(modalities, list):
|
| 25 |
-
normalized = {str(item or "").strip().lower() for item in modalities}
|
| 26 |
-
return "image" in normalized
|
| 27 |
-
return False
|
| 28 |
-
|
| 29 |
-
def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 30 |
-
if not self.is_image_chat_request(body):
|
| 31 |
-
raise HTTPException(
|
| 32 |
-
status_code=400,
|
| 33 |
-
detail={"error": "only image generation requests are supported on this endpoint"},
|
| 34 |
-
)
|
| 35 |
-
|
| 36 |
-
if bool(body.get("stream")):
|
| 37 |
-
raise HTTPException(status_code=400, detail={"error": "stream is not supported for image generation"})
|
| 38 |
-
|
| 39 |
-
model = str(body.get("model") or "gpt-image-1").strip() or "gpt-image-1"
|
| 40 |
-
n = self._parse_image_count(body.get("n"))
|
| 41 |
-
prompt = self._extract_chat_prompt(body)
|
| 42 |
-
if not prompt:
|
| 43 |
-
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 44 |
-
|
| 45 |
-
try:
|
| 46 |
-
image_result = self.backend_service.generate_with_pool(prompt, model, n)
|
| 47 |
-
except ImageGenerationError as exc:
|
| 48 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 49 |
-
|
| 50 |
-
return self._build_chat_image_completion(model, prompt, image_result)
|
| 51 |
-
|
| 52 |
-
def _extract_prompt_from_message_content(self, content: object) -> str:
|
| 53 |
-
if isinstance(content, str):
|
| 54 |
-
return content.strip()
|
| 55 |
-
if not isinstance(content, list):
|
| 56 |
-
return ""
|
| 57 |
-
|
| 58 |
-
parts: list[str] = []
|
| 59 |
-
for item in content:
|
| 60 |
-
if not isinstance(item, dict):
|
| 61 |
-
continue
|
| 62 |
-
item_type = str(item.get("type") or "").strip()
|
| 63 |
-
if item_type == "text":
|
| 64 |
-
text = str(item.get("text") or "").strip()
|
| 65 |
-
if text:
|
| 66 |
-
parts.append(text)
|
| 67 |
-
continue
|
| 68 |
-
if item_type == "input_text":
|
| 69 |
-
text = str(item.get("input_text") or "").strip()
|
| 70 |
-
if text:
|
| 71 |
-
parts.append(text)
|
| 72 |
-
return "\n".join(parts).strip()
|
| 73 |
-
|
| 74 |
-
def _extract_chat_prompt(self, body: dict[str, object]) -> str:
|
| 75 |
-
direct_prompt = str(body.get("prompt") or "").strip()
|
| 76 |
-
if direct_prompt:
|
| 77 |
-
return direct_prompt
|
| 78 |
-
|
| 79 |
-
messages = body.get("messages")
|
| 80 |
-
if not isinstance(messages, list):
|
| 81 |
-
return ""
|
| 82 |
-
|
| 83 |
-
prompt_parts: list[str] = []
|
| 84 |
-
for message in messages:
|
| 85 |
-
if not isinstance(message, dict):
|
| 86 |
-
continue
|
| 87 |
-
role = str(message.get("role") or "").strip().lower()
|
| 88 |
-
if role != "user":
|
| 89 |
-
continue
|
| 90 |
-
prompt = self._extract_prompt_from_message_content(message.get("content"))
|
| 91 |
-
if prompt:
|
| 92 |
-
prompt_parts.append(prompt)
|
| 93 |
-
|
| 94 |
-
return "\n".join(prompt_parts).strip()
|
| 95 |
-
|
| 96 |
-
def _parse_image_count(self, raw_value: object) -> int:
|
| 97 |
-
try:
|
| 98 |
-
value = int(raw_value or 1)
|
| 99 |
-
except (TypeError, ValueError) as exc:
|
| 100 |
-
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
| 101 |
-
if value < 1 or value > 4:
|
| 102 |
-
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 103 |
-
return value
|
| 104 |
-
|
| 105 |
-
def _build_chat_image_completion(
|
| 106 |
-
self,
|
| 107 |
-
model: str,
|
| 108 |
-
prompt: str,
|
| 109 |
-
image_result: dict[str, object],
|
| 110 |
-
) -> dict[str, object]:
|
| 111 |
-
created = int(image_result.get("created") or time.time())
|
| 112 |
-
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 113 |
-
|
| 114 |
-
markdown_images = []
|
| 115 |
-
images_payload = []
|
| 116 |
-
content_parts = []
|
| 117 |
-
|
| 118 |
-
for index, item in enumerate(image_items, start=1):
|
| 119 |
-
if not isinstance(item, dict):
|
| 120 |
-
continue
|
| 121 |
-
b64_json = str(item.get("b64_json") or "").strip()
|
| 122 |
-
revised_prompt = str(item.get("revised_prompt") or prompt).strip()
|
| 123 |
-
if not b64_json:
|
| 124 |
-
continue
|
| 125 |
-
image_data_url = f"data:image/png;base64,{b64_json}"
|
| 126 |
-
markdown_images.append(f"")
|
| 127 |
-
images_payload.append(
|
| 128 |
-
{
|
| 129 |
-
"b64_json": b64_json,
|
| 130 |
-
"revised_prompt": revised_prompt,
|
| 131 |
-
"url": image_data_url,
|
| 132 |
-
}
|
| 133 |
-
)
|
| 134 |
-
content_parts.append(
|
| 135 |
-
{
|
| 136 |
-
"type": "image_url",
|
| 137 |
-
"image_url": {
|
| 138 |
-
"url": image_data_url,
|
| 139 |
-
},
|
| 140 |
-
}
|
| 141 |
-
)
|
| 142 |
-
|
| 143 |
-
text_content = "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
|
| 144 |
-
content_parts.insert(
|
| 145 |
-
0,
|
| 146 |
-
{
|
| 147 |
-
"type": "text",
|
| 148 |
-
"text": text_content,
|
| 149 |
-
},
|
| 150 |
-
)
|
| 151 |
-
|
| 152 |
-
return {
|
| 153 |
-
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 154 |
-
"object": "chat.completion",
|
| 155 |
-
"created": created,
|
| 156 |
-
"model": model,
|
| 157 |
-
"choices": [
|
| 158 |
-
{
|
| 159 |
-
"index": 0,
|
| 160 |
-
"message": {
|
| 161 |
-
"role": "assistant",
|
| 162 |
-
"content": text_content,
|
| 163 |
-
"content_parts": content_parts,
|
| 164 |
-
"images": images_payload,
|
| 165 |
-
},
|
| 166 |
-
"finish_reason": "stop",
|
| 167 |
-
}
|
| 168 |
-
],
|
| 169 |
-
"usage": {
|
| 170 |
-
"prompt_tokens": 0,
|
| 171 |
-
"completion_tokens": 0,
|
| 172 |
-
"total_tokens": 0,
|
| 173 |
-
},
|
| 174 |
-
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/chatgpt_service.py
ADDED
|
@@ -0,0 +1,143 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from fastapi import HTTPException
|
| 4 |
+
|
| 5 |
+
from services.account_service import AccountService
|
| 6 |
+
from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
|
| 7 |
+
from services.utils import (
|
| 8 |
+
build_chat_image_completion,
|
| 9 |
+
extract_chat_prompt,
|
| 10 |
+
extract_response_prompt,
|
| 11 |
+
has_response_image_generation_tool,
|
| 12 |
+
is_image_chat_request,
|
| 13 |
+
parse_image_count,
|
| 14 |
+
)
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
class ChatGPTService:
|
| 18 |
+
def __init__(self, account_service: AccountService):
|
| 19 |
+
self.account_service = account_service
|
| 20 |
+
|
| 21 |
+
def generate_with_pool(self, prompt: str, model: str, n: int):
|
| 22 |
+
created = None
|
| 23 |
+
image_items: list[dict[str, object]] = []
|
| 24 |
+
|
| 25 |
+
for index in range(1, n + 1):
|
| 26 |
+
while True:
|
| 27 |
+
try:
|
| 28 |
+
request_token = self.account_service.get_available_access_token()
|
| 29 |
+
except RuntimeError as exc:
|
| 30 |
+
print(f"[image-generate] stop index={index}/{n} error={exc}")
|
| 31 |
+
break
|
| 32 |
+
|
| 33 |
+
print(f"[image-generate] start pooled token={request_token[:12]}... model={model} index={index}/{n}")
|
| 34 |
+
try:
|
| 35 |
+
result = generate_image_result(request_token, prompt, model)
|
| 36 |
+
account = self.account_service.mark_image_result(request_token, success=True)
|
| 37 |
+
if created is None:
|
| 38 |
+
created = result.get("created")
|
| 39 |
+
data = result.get("data")
|
| 40 |
+
if isinstance(data, list):
|
| 41 |
+
image_items.extend(item for item in data if isinstance(item, dict))
|
| 42 |
+
print(
|
| 43 |
+
f"[image-generate] success pooled token={request_token[:12]}... "
|
| 44 |
+
f"quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
|
| 45 |
+
)
|
| 46 |
+
break
|
| 47 |
+
except ImageGenerationError as exc:
|
| 48 |
+
account = self.account_service.mark_image_result(request_token, success=False)
|
| 49 |
+
message = str(exc)
|
| 50 |
+
print(
|
| 51 |
+
f"[image-generate] fail pooled token={request_token[:12]}... "
|
| 52 |
+
f"error={message} quota={account.get('quota') if account else 'unknown'} status={account.get('status') if account else 'unknown'}"
|
| 53 |
+
)
|
| 54 |
+
if is_token_invalid_error(message):
|
| 55 |
+
self.account_service.remove_token(request_token)
|
| 56 |
+
print(f"[image-generate] remove invalid token={request_token[:12]}...")
|
| 57 |
+
continue
|
| 58 |
+
break
|
| 59 |
+
|
| 60 |
+
if not image_items:
|
| 61 |
+
raise ImageGenerationError("image generation failed")
|
| 62 |
+
|
| 63 |
+
return {
|
| 64 |
+
"created": created,
|
| 65 |
+
"data": image_items,
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 69 |
+
if not is_image_chat_request(body):
|
| 70 |
+
raise HTTPException(
|
| 71 |
+
status_code=400,
|
| 72 |
+
detail={"error": "only image generation requests are supported on this endpoint"},
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
if bool(body.get("stream")):
|
| 76 |
+
raise HTTPException(status_code=400, detail={"error": "stream is not supported for image generation"})
|
| 77 |
+
|
| 78 |
+
model = str(body.get("model") or "gpt-image-1").strip() or "gpt-image-1"
|
| 79 |
+
n = parse_image_count(body.get("n"))
|
| 80 |
+
prompt = extract_chat_prompt(body)
|
| 81 |
+
if not prompt:
|
| 82 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 83 |
+
|
| 84 |
+
try:
|
| 85 |
+
image_result = self.generate_with_pool(prompt, model, n)
|
| 86 |
+
except ImageGenerationError as exc:
|
| 87 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 88 |
+
|
| 89 |
+
return build_chat_image_completion(model, prompt, image_result)
|
| 90 |
+
|
| 91 |
+
def create_response(self, body: dict[str, object]) -> dict[str, object]:
|
| 92 |
+
if bool(body.get("stream")):
|
| 93 |
+
raise HTTPException(status_code=400, detail={"error": "stream is not supported"})
|
| 94 |
+
|
| 95 |
+
if not has_response_image_generation_tool(body):
|
| 96 |
+
raise HTTPException(
|
| 97 |
+
status_code=400,
|
| 98 |
+
detail={"error": "only image_generation tool requests are supported on this endpoint"},
|
| 99 |
+
)
|
| 100 |
+
|
| 101 |
+
prompt = extract_response_prompt(body.get("input"))
|
| 102 |
+
if not prompt:
|
| 103 |
+
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 104 |
+
|
| 105 |
+
model = str(body.get("model") or "gpt-5").strip() or "gpt-5"
|
| 106 |
+
try:
|
| 107 |
+
image_result = self.generate_with_pool(prompt, "gpt-image-1", 1)
|
| 108 |
+
except ImageGenerationError as exc:
|
| 109 |
+
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 110 |
+
|
| 111 |
+
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 112 |
+
output = []
|
| 113 |
+
for item in image_items:
|
| 114 |
+
if not isinstance(item, dict):
|
| 115 |
+
continue
|
| 116 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 117 |
+
if not b64_json:
|
| 118 |
+
continue
|
| 119 |
+
output.append(
|
| 120 |
+
{
|
| 121 |
+
"id": f"ig_{len(output) + 1}",
|
| 122 |
+
"type": "image_generation_call",
|
| 123 |
+
"status": "completed",
|
| 124 |
+
"result": b64_json,
|
| 125 |
+
"revised_prompt": str(item.get("revised_prompt") or prompt).strip(),
|
| 126 |
+
}
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
if not output:
|
| 130 |
+
raise HTTPException(status_code=502, detail={"error": "image generation failed"})
|
| 131 |
+
|
| 132 |
+
created = int(image_result.get("created") or 0)
|
| 133 |
+
return {
|
| 134 |
+
"id": f"resp_{created}",
|
| 135 |
+
"object": "response",
|
| 136 |
+
"created_at": created,
|
| 137 |
+
"status": "completed",
|
| 138 |
+
"error": None,
|
| 139 |
+
"incomplete_details": None,
|
| 140 |
+
"model": model,
|
| 141 |
+
"output": output,
|
| 142 |
+
"parallel_tool_calls": False,
|
| 143 |
+
}
|
services/image_service.py
CHANGED
|
@@ -442,71 +442,65 @@ def _resolve_upstream_model(access_token: str, requested_model: str) -> str:
|
|
| 442 |
return str(requested_model or DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
| 443 |
|
| 444 |
|
| 445 |
-
def generate_image_result(access_token: str, prompt: str, model: str = DEFAULT_MODEL
|
| 446 |
prompt = str(prompt or "").strip()
|
| 447 |
access_token = str(access_token or "").strip()
|
| 448 |
if not prompt:
|
| 449 |
raise ImageGenerationError("prompt is required")
|
| 450 |
if not access_token:
|
| 451 |
raise ImageGenerationError("token is required")
|
| 452 |
-
if n < 1:
|
| 453 |
-
raise ImageGenerationError("n must be >= 1")
|
| 454 |
|
| 455 |
session, fp = _new_session(access_token)
|
| 456 |
try:
|
| 457 |
upstream_model = _resolve_upstream_model(access_token, model)
|
| 458 |
print(
|
| 459 |
f"[image-upstream] start token={access_token[:12]}... "
|
| 460 |
-
f"requested_model={model} upstream_model={upstream_model}
|
| 461 |
)
|
| 462 |
-
|
| 463 |
-
|
| 464 |
-
|
| 465 |
-
|
| 466 |
-
proof_token =
|
| 467 |
-
|
| 468 |
-
|
| 469 |
-
|
| 470 |
-
|
| 471 |
-
user_agent=USER_AGENT,
|
| 472 |
-
proof_config=_pow_config(USER_AGENT),
|
| 473 |
-
)
|
| 474 |
-
parent_message_id = str(uuid.uuid4())
|
| 475 |
-
response = _send_conversation(
|
| 476 |
-
session,
|
| 477 |
-
access_token,
|
| 478 |
-
device_id,
|
| 479 |
-
chat_token,
|
| 480 |
-
proof_token,
|
| 481 |
-
parent_message_id,
|
| 482 |
-
prompt,
|
| 483 |
-
upstream_model,
|
| 484 |
)
|
| 485 |
-
|
| 486 |
-
|
| 487 |
-
|
| 488 |
-
|
| 489 |
-
|
| 490 |
-
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
|
| 496 |
-
|
| 497 |
-
|
| 498 |
-
|
| 499 |
-
|
| 500 |
-
|
| 501 |
-
|
| 502 |
-
|
| 503 |
-
|
| 504 |
-
)
|
| 505 |
-
)
|
| 506 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 507 |
return {
|
| 508 |
"created": time.time_ns() // 1_000_000_000,
|
| 509 |
-
"data": [{"b64_json":
|
| 510 |
}
|
| 511 |
except Exception as exc:
|
| 512 |
print(f"[image-upstream] fail token={access_token[:12]}... error={exc}")
|
|
|
|
| 442 |
return str(requested_model or DEFAULT_MODEL).strip() or DEFAULT_MODEL
|
| 443 |
|
| 444 |
|
| 445 |
+
def generate_image_result(access_token: str, prompt: str, model: str = DEFAULT_MODEL) -> dict:
|
| 446 |
prompt = str(prompt or "").strip()
|
| 447 |
access_token = str(access_token or "").strip()
|
| 448 |
if not prompt:
|
| 449 |
raise ImageGenerationError("prompt is required")
|
| 450 |
if not access_token:
|
| 451 |
raise ImageGenerationError("token is required")
|
|
|
|
|
|
|
| 452 |
|
| 453 |
session, fp = _new_session(access_token)
|
| 454 |
try:
|
| 455 |
upstream_model = _resolve_upstream_model(access_token, model)
|
| 456 |
print(
|
| 457 |
f"[image-upstream] start token={access_token[:12]}... "
|
| 458 |
+
f"requested_model={model} upstream_model={upstream_model}"
|
| 459 |
)
|
| 460 |
+
device_id = _bootstrap(session, fp)
|
| 461 |
+
chat_token, pow_info = _chat_requirements(session, access_token, device_id)
|
| 462 |
+
proof_token = None
|
| 463 |
+
if pow_info.get("required"):
|
| 464 |
+
proof_token = _generate_proof_token(
|
| 465 |
+
seed=str(pow_info["seed"]),
|
| 466 |
+
difficulty=str(pow_info["difficulty"]),
|
| 467 |
+
user_agent=USER_AGENT,
|
| 468 |
+
proof_config=_pow_config(USER_AGENT),
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 469 |
)
|
| 470 |
+
parent_message_id = str(uuid.uuid4())
|
| 471 |
+
response = _send_conversation(
|
| 472 |
+
session,
|
| 473 |
+
access_token,
|
| 474 |
+
device_id,
|
| 475 |
+
chat_token,
|
| 476 |
+
proof_token,
|
| 477 |
+
parent_message_id,
|
| 478 |
+
prompt,
|
| 479 |
+
upstream_model,
|
| 480 |
+
)
|
| 481 |
+
parsed = _parse_sse(response)
|
| 482 |
+
actual_conversation_id = parsed.get("conversation_id") or ""
|
| 483 |
+
file_ids = parsed.get("file_ids") or []
|
| 484 |
+
response_text = str(parsed.get("text") or "").strip()
|
| 485 |
+
if actual_conversation_id and not file_ids:
|
| 486 |
+
file_ids = _poll_image_ids(session, access_token, device_id, actual_conversation_id)
|
| 487 |
+
if not file_ids:
|
| 488 |
+
if response_text:
|
| 489 |
+
raise ImageGenerationError(response_text)
|
| 490 |
+
raise ImageGenerationError("no image returned from upstream")
|
| 491 |
+
first_file_id = str(file_ids[0])
|
| 492 |
+
download_url = _fetch_download_url(session, access_token, device_id, actual_conversation_id, first_file_id)
|
| 493 |
+
if not download_url:
|
| 494 |
+
raise ImageGenerationError("failed to get download url")
|
| 495 |
+
result = GeneratedImage(
|
| 496 |
+
b64_json=_download_as_base64(session, download_url),
|
| 497 |
+
revised_prompt=prompt,
|
| 498 |
+
url=download_url,
|
| 499 |
+
)
|
| 500 |
+
print(f"[image-upstream] success token={access_token[:12]}... images=1")
|
| 501 |
return {
|
| 502 |
"created": time.time_ns() // 1_000_000_000,
|
| 503 |
+
"data": [{"b64_json": result.b64_json, "revised_prompt": result.revised_prompt}],
|
| 504 |
}
|
| 505 |
except Exception as exc:
|
| 506 |
print(f"[image-upstream] fail token={access_token[:12]}... error={exc}")
|
services/proof_of_work.py
CHANGED
|
@@ -9,13 +9,11 @@ from html.parser import HTMLParser
|
|
| 9 |
|
| 10 |
import pybase64
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
logger = _Logger()
|
| 19 |
|
| 20 |
cores = [8, 16, 24, 32]
|
| 21 |
timeLayout = "%a %b %d %Y %H:%M:%S"
|
|
@@ -402,7 +400,6 @@ def get_data_build_from_html(html_content):
|
|
| 402 |
data_build = match.group(1)
|
| 403 |
cached_dpl = data_build
|
| 404 |
cached_time = int(time.time())
|
| 405 |
-
logger.info(f"Found dpl: {cached_dpl}")
|
| 406 |
|
| 407 |
|
| 408 |
async def get_dpl(service):
|
|
@@ -421,7 +418,6 @@ async def get_dpl(service):
|
|
| 421 |
else:
|
| 422 |
return True
|
| 423 |
except Exception as e:
|
| 424 |
-
logger.info(f"Failed to get dpl: {e}")
|
| 425 |
cached_dpl = None
|
| 426 |
cached_time = int(time.time())
|
| 427 |
return False
|
|
@@ -460,7 +456,6 @@ def get_answer_token(seed, diff, config):
|
|
| 460 |
start = time.time()
|
| 461 |
answer, solved = generate_answer(seed, diff, config)
|
| 462 |
end = time.time()
|
| 463 |
-
logger.info(f'diff: {diff}, time: {int((end - start) * 1e6) / 1e3}ms, solved: {solved}')
|
| 464 |
return "gAAAAAB" + answer, solved
|
| 465 |
|
| 466 |
|
|
@@ -488,20 +483,3 @@ def generate_answer(seed, diff, config):
|
|
| 488 |
def get_requirements_token(config):
|
| 489 |
require, solved = generate_answer(format(random.random()), "0fffff", config)
|
| 490 |
return 'gAAAAAC' + require
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
if __name__ == "__main__":
|
| 494 |
-
# cached_scripts.append(
|
| 495 |
-
# "https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3")
|
| 496 |
-
# cached_dpl = "453ebaec0d44c2decab71692e1bfe39be35a24b3"
|
| 497 |
-
# cached_time = int(time.time())
|
| 498 |
-
# for i in range(10):
|
| 499 |
-
# seed = format(random.random())
|
| 500 |
-
# diff = "000032"
|
| 501 |
-
# config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome")
|
| 502 |
-
# answer = get_answer_token(seed, diff, config)
|
| 503 |
-
cached_scripts.append(
|
| 504 |
-
"https://cdn.oaistatic.com/_next/static/cXh69klOLzS0Gy2joLDRS/_ssgManifest.js?dpl=453ebaec0d44c2decab71692e1bfe39be35a24b3")
|
| 505 |
-
cached_dpl = "prod-f501fe933b3edf57aea882da888e1a544df99840"
|
| 506 |
-
config = get_config("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/127.0.0.0 Safari/537.36")
|
| 507 |
-
get_requirements_token(config)
|
|
|
|
| 9 |
|
| 10 |
import pybase64
|
| 11 |
|
| 12 |
+
"""
|
| 13 |
+
这个文件用于生成上游 ChatGPT 网页接口需要的 PoW 参数。
|
| 14 |
+
它会解析首页 HTML 并缓存构建信息,组装浏览器指纹风格的配置数据,
|
| 15 |
+
然后生成图片请求流程里会用到的 requirements token 和 proof token。
|
| 16 |
+
"""
|
|
|
|
|
|
|
| 17 |
|
| 18 |
cores = [8, 16, 24, 32]
|
| 19 |
timeLayout = "%a %b %d %Y %H:%M:%S"
|
|
|
|
| 400 |
data_build = match.group(1)
|
| 401 |
cached_dpl = data_build
|
| 402 |
cached_time = int(time.time())
|
|
|
|
| 403 |
|
| 404 |
|
| 405 |
async def get_dpl(service):
|
|
|
|
| 418 |
else:
|
| 419 |
return True
|
| 420 |
except Exception as e:
|
|
|
|
| 421 |
cached_dpl = None
|
| 422 |
cached_time = int(time.time())
|
| 423 |
return False
|
|
|
|
| 456 |
start = time.time()
|
| 457 |
answer, solved = generate_answer(seed, diff, config)
|
| 458 |
end = time.time()
|
|
|
|
| 459 |
return "gAAAAAB" + answer, solved
|
| 460 |
|
| 461 |
|
|
|
|
| 483 |
def get_requirements_token(config):
|
| 484 |
require, solved = generate_answer(format(random.random()), "0fffff", config)
|
| 485 |
return 'gAAAAAC' + require
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/test_image.py
DELETED
|
@@ -1,98 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import base64
|
| 4 |
-
import json
|
| 5 |
-
import sys
|
| 6 |
-
import time
|
| 7 |
-
from pathlib import Path
|
| 8 |
-
|
| 9 |
-
ROOT_DIR = Path(__file__).resolve().parents[2]
|
| 10 |
-
if str(ROOT_DIR) not in sys.path:
|
| 11 |
-
sys.path.insert(0, str(ROOT_DIR))
|
| 12 |
-
|
| 13 |
-
from services.account_service import account_service
|
| 14 |
-
from services.image_service import ImageGenerationError, generate_image_result, is_token_invalid_error
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
OUTPUT_DIR = ROOT_DIR / "data" / "output"
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
def detect_ext(image_bytes: bytes) -> str:
|
| 21 |
-
if image_bytes.startswith(b"\xff\xd8\xff"):
|
| 22 |
-
return ".jpg"
|
| 23 |
-
if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
|
| 24 |
-
return ".webp"
|
| 25 |
-
if image_bytes.startswith(b"GIF87a") or image_bytes.startswith(b"GIF89a"):
|
| 26 |
-
return ".gif"
|
| 27 |
-
return ".png"
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
def save_image(image_b64: str, index: int) -> Path:
|
| 31 |
-
image_bytes = base64.b64decode(image_b64)
|
| 32 |
-
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 33 |
-
path = OUTPUT_DIR / f"image_{index}_{int(time.time())}{detect_ext(image_bytes)}"
|
| 34 |
-
path.write_bytes(image_bytes)
|
| 35 |
-
return path
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
def generate_image_direct(prompt: str, model: str = "gpt-5-3", n: int = 1) -> dict:
|
| 39 |
-
tokens = account_service.list_tokens()
|
| 40 |
-
if not tokens:
|
| 41 |
-
raise ImageGenerationError(f"No tokens found in {account_service.store_file}")
|
| 42 |
-
|
| 43 |
-
last_error: Exception | None = None
|
| 44 |
-
for index, token in enumerate(tokens, start=1):
|
| 45 |
-
try:
|
| 46 |
-
print(f"try token {index}/{len(tokens)}")
|
| 47 |
-
return generate_image_result(token, prompt, model, n)
|
| 48 |
-
except ImageGenerationError as exc:
|
| 49 |
-
last_error = exc
|
| 50 |
-
if is_token_invalid_error(str(exc)):
|
| 51 |
-
account_service.remove_token(token)
|
| 52 |
-
print(f"skip invalid token {index}/{len(tokens)}")
|
| 53 |
-
continue
|
| 54 |
-
raise
|
| 55 |
-
|
| 56 |
-
if last_error is not None:
|
| 57 |
-
raise last_error
|
| 58 |
-
raise ImageGenerationError("No usable token")
|
| 59 |
-
|
| 60 |
-
|
| 61 |
-
def main() -> int:
|
| 62 |
-
prompt = " ".join(sys.argv[1:]).strip()
|
| 63 |
-
if not prompt:
|
| 64 |
-
prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
|
| 65 |
-
|
| 66 |
-
print(f"prompt: {prompt}")
|
| 67 |
-
try:
|
| 68 |
-
print("request: direct image_service")
|
| 69 |
-
data = generate_image_direct(prompt)
|
| 70 |
-
except ImageGenerationError as exc:
|
| 71 |
-
print(f"image generation error: {exc}")
|
| 72 |
-
return 1
|
| 73 |
-
|
| 74 |
-
items = data.get("data") or []
|
| 75 |
-
if not items:
|
| 76 |
-
print("error: response data is empty")
|
| 77 |
-
print(json.dumps(data, ensure_ascii=False, indent=2))
|
| 78 |
-
return 1
|
| 79 |
-
|
| 80 |
-
saved = []
|
| 81 |
-
for index, item in enumerate(items, start=1):
|
| 82 |
-
image_b64 = str((item or {}).get("b64_json") or "").strip()
|
| 83 |
-
if not image_b64:
|
| 84 |
-
continue
|
| 85 |
-
path = save_image(image_b64, index)
|
| 86 |
-
saved.append(path)
|
| 87 |
-
print(f"saved: {path}")
|
| 88 |
-
|
| 89 |
-
if not saved:
|
| 90 |
-
print("error: no b64_json returned")
|
| 91 |
-
print(json.dumps(data, ensure_ascii=False, indent=2))
|
| 92 |
-
return 1
|
| 93 |
-
|
| 94 |
-
return 0
|
| 95 |
-
|
| 96 |
-
|
| 97 |
-
if __name__ == "__main__":
|
| 98 |
-
raise SystemExit(main())
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/utils.py
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
IMAGE_MODELS = {"gpt-image-1", "gpt-image-2"}
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def is_image_chat_request(body: dict[str, object]) -> bool:
|
| 13 |
+
model = str(body.get("model") or "").strip()
|
| 14 |
+
modalities = body.get("modalities")
|
| 15 |
+
if model in IMAGE_MODELS:
|
| 16 |
+
return True
|
| 17 |
+
if isinstance(modalities, list):
|
| 18 |
+
normalized = {str(item or "").strip().lower() for item in modalities}
|
| 19 |
+
return "image" in normalized
|
| 20 |
+
return False
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def extract_response_prompt(input_value: object) -> str:
|
| 24 |
+
if isinstance(input_value, str):
|
| 25 |
+
return input_value.strip()
|
| 26 |
+
|
| 27 |
+
if isinstance(input_value, dict):
|
| 28 |
+
role = str(input_value.get("role") or "").strip().lower()
|
| 29 |
+
if role and role != "user":
|
| 30 |
+
return ""
|
| 31 |
+
return extract_prompt_from_message_content(input_value.get("content"))
|
| 32 |
+
|
| 33 |
+
if not isinstance(input_value, list):
|
| 34 |
+
return ""
|
| 35 |
+
|
| 36 |
+
prompt_parts: list[str] = []
|
| 37 |
+
for item in input_value:
|
| 38 |
+
if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text":
|
| 39 |
+
text = str(item.get("text") or "").strip()
|
| 40 |
+
if text:
|
| 41 |
+
prompt_parts.append(text)
|
| 42 |
+
continue
|
| 43 |
+
if not isinstance(item, dict):
|
| 44 |
+
continue
|
| 45 |
+
role = str(item.get("role") or "").strip().lower()
|
| 46 |
+
if role and role != "user":
|
| 47 |
+
continue
|
| 48 |
+
prompt = extract_prompt_from_message_content(item.get("content"))
|
| 49 |
+
if prompt:
|
| 50 |
+
prompt_parts.append(prompt)
|
| 51 |
+
return "\n".join(prompt_parts).strip()
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
def has_response_image_generation_tool(body: dict[str, object]) -> bool:
|
| 55 |
+
tools = body.get("tools")
|
| 56 |
+
if isinstance(tools, list):
|
| 57 |
+
for tool in tools:
|
| 58 |
+
if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
|
| 59 |
+
return True
|
| 60 |
+
|
| 61 |
+
tool_choice = body.get("tool_choice")
|
| 62 |
+
if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation":
|
| 63 |
+
return True
|
| 64 |
+
return False
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
def extract_prompt_from_message_content(content: object) -> str:
|
| 68 |
+
if isinstance(content, str):
|
| 69 |
+
return content.strip()
|
| 70 |
+
if not isinstance(content, list):
|
| 71 |
+
return ""
|
| 72 |
+
|
| 73 |
+
parts: list[str] = []
|
| 74 |
+
for item in content:
|
| 75 |
+
if not isinstance(item, dict):
|
| 76 |
+
continue
|
| 77 |
+
item_type = str(item.get("type") or "").strip()
|
| 78 |
+
if item_type == "text":
|
| 79 |
+
text = str(item.get("text") or "").strip()
|
| 80 |
+
if text:
|
| 81 |
+
parts.append(text)
|
| 82 |
+
continue
|
| 83 |
+
if item_type == "input_text":
|
| 84 |
+
text = str(item.get("text") or item.get("input_text") or "").strip()
|
| 85 |
+
if text:
|
| 86 |
+
parts.append(text)
|
| 87 |
+
return "\n".join(parts).strip()
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
def extract_chat_prompt(body: dict[str, object]) -> str:
|
| 91 |
+
direct_prompt = str(body.get("prompt") or "").strip()
|
| 92 |
+
if direct_prompt:
|
| 93 |
+
return direct_prompt
|
| 94 |
+
|
| 95 |
+
messages = body.get("messages")
|
| 96 |
+
if not isinstance(messages, list):
|
| 97 |
+
return ""
|
| 98 |
+
|
| 99 |
+
prompt_parts: list[str] = []
|
| 100 |
+
for message in messages:
|
| 101 |
+
if not isinstance(message, dict):
|
| 102 |
+
continue
|
| 103 |
+
role = str(message.get("role") or "").strip().lower()
|
| 104 |
+
if role != "user":
|
| 105 |
+
continue
|
| 106 |
+
prompt = extract_prompt_from_message_content(message.get("content"))
|
| 107 |
+
if prompt:
|
| 108 |
+
prompt_parts.append(prompt)
|
| 109 |
+
|
| 110 |
+
return "\n".join(prompt_parts).strip()
|
| 111 |
+
|
| 112 |
+
|
| 113 |
+
def parse_image_count(raw_value: object) -> int:
|
| 114 |
+
try:
|
| 115 |
+
value = int(raw_value or 1)
|
| 116 |
+
except (TypeError, ValueError) as exc:
|
| 117 |
+
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
| 118 |
+
if value < 1 or value > 4:
|
| 119 |
+
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 120 |
+
return value
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
def build_chat_image_completion(
|
| 124 |
+
model: str,
|
| 125 |
+
prompt: str,
|
| 126 |
+
image_result: dict[str, object],
|
| 127 |
+
) -> dict[str, object]:
|
| 128 |
+
created = int(image_result.get("created") or time.time())
|
| 129 |
+
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 130 |
+
|
| 131 |
+
markdown_images = []
|
| 132 |
+
|
| 133 |
+
for index, item in enumerate(image_items, start=1):
|
| 134 |
+
if not isinstance(item, dict):
|
| 135 |
+
continue
|
| 136 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 137 |
+
if not b64_json:
|
| 138 |
+
continue
|
| 139 |
+
image_data_url = f"data:image/png;base64,{b64_json}"
|
| 140 |
+
markdown_images.append(f"")
|
| 141 |
+
|
| 142 |
+
text_content = "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
|
| 143 |
+
|
| 144 |
+
return {
|
| 145 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 146 |
+
"object": "chat.completion",
|
| 147 |
+
"created": created,
|
| 148 |
+
"model": model,
|
| 149 |
+
"choices": [
|
| 150 |
+
{
|
| 151 |
+
"index": 0,
|
| 152 |
+
"message": {
|
| 153 |
+
"role": "assistant",
|
| 154 |
+
"content": text_content,
|
| 155 |
+
},
|
| 156 |
+
"finish_reason": "stop",
|
| 157 |
+
}
|
| 158 |
+
],
|
| 159 |
+
"usage": {
|
| 160 |
+
"prompt_tokens": 0,
|
| 161 |
+
"completion_tokens": 0,
|
| 162 |
+
"total_tokens": 0,
|
| 163 |
+
},
|
| 164 |
+
}
|
test/test_completions.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from test.utils import post_json, save_image
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def main() -> None:
|
| 5 |
+
prompt = "A cute orange cat sitting on a chair"
|
| 6 |
+
result = post_json("/v1/chat/completions", {"model": "gpt-image-1", "stream": False, "messages": [{"role": "user", "content": prompt}]})
|
| 7 |
+
for index, item in enumerate(result["choices"][0]["message"]["images"], start=1):
|
| 8 |
+
print(save_image(item["b64_json"], f"completions_{index}"))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
main()
|
test/test_generations.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from test.utils import post_json, save_image
|
| 2 |
+
|
| 3 |
+
|
| 4 |
+
def main() -> None:
|
| 5 |
+
prompt = "A cute orange cat sitting on a chair"
|
| 6 |
+
result = post_json("/v1/images/generations", {"prompt": prompt, "model": "gpt-image-1", "n": 1})
|
| 7 |
+
for index, item in enumerate(result["data"], start=1):
|
| 8 |
+
print(save_image(item["b64_json"], f"generations_{index}"))
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
if __name__ == "__main__":
|
| 12 |
+
main()
|
test/test_image.py
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from services.account_service import account_service
|
| 4 |
+
from services.chatgpt_service import ChatGPTService
|
| 5 |
+
from test.utils import save_image
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def main() -> None:
|
| 9 |
+
prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
|
| 10 |
+
data = ChatGPTService(account_service).generate_with_pool(prompt, "gpt-5-3", 1)
|
| 11 |
+
for index, item in enumerate(data["data"], start=1):
|
| 12 |
+
print(save_image(item["b64_json"], f"image_{index}"))
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
if __name__ == "__main__":
|
| 16 |
+
main()
|
test/utils.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import base64
|
| 2 |
+
import json
|
| 3 |
+
import sys
|
| 4 |
+
import time
|
| 5 |
+
import urllib.request
|
| 6 |
+
from pathlib import Path
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
ROOT_DIR = Path(__file__).resolve().parents[1]
|
| 10 |
+
OUTPUT_DIR = ROOT_DIR / "data" / "output"
|
| 11 |
+
BASE_URL = "http://127.0.0.1:8000"
|
| 12 |
+
|
| 13 |
+
if str(ROOT_DIR) not in sys.path:
|
| 14 |
+
sys.path.insert(0, str(ROOT_DIR))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def load_auth_key() -> str:
|
| 18 |
+
return json.loads((ROOT_DIR / "config.json").read_text(encoding="utf-8"))["auth-key"]
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def post_json(path: str, payload: dict) -> dict:
|
| 22 |
+
request = urllib.request.Request(
|
| 23 |
+
BASE_URL + path,
|
| 24 |
+
data=json.dumps(payload).encode(),
|
| 25 |
+
headers={"Content-Type": "application/json", "Authorization": f"Bearer {load_auth_key()}"},
|
| 26 |
+
method="POST",
|
| 27 |
+
)
|
| 28 |
+
with urllib.request.urlopen(request) as response:
|
| 29 |
+
return json.loads(response.read().decode())
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
def detect_ext(image_bytes: bytes) -> str:
|
| 33 |
+
if image_bytes.startswith(b"\xff\xd8\xff"):
|
| 34 |
+
return ".jpg"
|
| 35 |
+
if image_bytes.startswith(b"RIFF") and image_bytes[8:12] == b"WEBP":
|
| 36 |
+
return ".webp"
|
| 37 |
+
if image_bytes.startswith((b"GIF87a", b"GIF89a")):
|
| 38 |
+
return ".gif"
|
| 39 |
+
return ".png"
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def save_image(image_b64: str, name: str) -> Path:
|
| 43 |
+
image_bytes = base64.b64decode(image_b64)
|
| 44 |
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 45 |
+
path = OUTPUT_DIR / f"{name}_{int(time.time())}{detect_ext(image_bytes)}"
|
| 46 |
+
path.write_bytes(image_bytes)
|
| 47 |
+
return path
|
web/next.config.ts
CHANGED
|
@@ -1,6 +1,25 @@
|
|
|
|
|
|
|
|
|
|
|
| 1 |
import type { NextConfig } from 'next'
|
| 2 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3 |
const nextConfig: NextConfig = {
|
|
|
|
|
|
|
|
|
|
| 4 |
output: 'export',
|
| 5 |
images: {
|
| 6 |
unoptimized: true,
|
|
|
|
| 1 |
+
import { readFileSync } from 'node:fs'
|
| 2 |
+
import { dirname, join } from 'node:path'
|
| 3 |
+
import { fileURLToPath } from 'node:url'
|
| 4 |
import type { NextConfig } from 'next'
|
| 5 |
|
| 6 |
+
const projectRoot = join(dirname(fileURLToPath(import.meta.url)), '..')
|
| 7 |
+
|
| 8 |
+
function readAppVersion() {
|
| 9 |
+
try {
|
| 10 |
+
const version = readFileSync(join(projectRoot, 'VERSION'), 'utf-8').trim()
|
| 11 |
+
return version || '0.0.0'
|
| 12 |
+
} catch {
|
| 13 |
+
return '0.0.0'
|
| 14 |
+
}
|
| 15 |
+
}
|
| 16 |
+
|
| 17 |
+
const appVersion = process.env.NEXT_PUBLIC_APP_VERSION || readAppVersion()
|
| 18 |
+
|
| 19 |
const nextConfig: NextConfig = {
|
| 20 |
+
env: {
|
| 21 |
+
NEXT_PUBLIC_APP_VERSION: appVersion,
|
| 22 |
+
},
|
| 23 |
output: 'export',
|
| 24 |
images: {
|
| 25 |
unoptimized: true,
|
web/package.json
CHANGED
|
@@ -6,12 +6,7 @@
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "next dev --webpack -H 0.0.0.0",
|
| 8 |
"build": "next build",
|
| 9 |
-
"start": "next start"
|
| 10 |
-
"lint": "eslint .",
|
| 11 |
-
"build:dev": "cp .dev .env.local && next build",
|
| 12 |
-
"build:test": "cp .test .env.local && next build",
|
| 13 |
-
"build:prod": "cp .prod .env.local && next build",
|
| 14 |
-
"openapi": "node openapi.config.js"
|
| 15 |
},
|
| 16 |
"dependencies": {
|
| 17 |
"@radix-ui/react-checkbox": "^1.3.3",
|
|
|
|
| 6 |
"scripts": {
|
| 7 |
"dev": "next dev --webpack -H 0.0.0.0",
|
| 8 |
"build": "next build",
|
| 9 |
+
"start": "next start"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
},
|
| 11 |
"dependencies": {
|
| 12 |
"@radix-ui/react-checkbox": "^1.3.3",
|