HouYunFei Codex commited on
Commit ·
bef1a50
1
Parent(s): 85b5785
增加图片管理、日志管理,优化敏感词等场景
Browse filesCo-Authored-By: Codex <noreply@openai.com>
- VERSION +1 -1
- api/ai.py +33 -165
- api/app.py +1 -4
- config.json +6 -0
- docs/upstream-sse-conversation.md +271 -0
- services/account_service.py +18 -6
- services/chatgpt_service.py +0 -1178
- services/config.py +9 -0
- services/log_service.py +124 -0
- services/openai_backend_api.py +104 -933
- services/protocol/__init__.py +2 -0
- services/{anthropic_protocol.py → protocol/anthropic_v1_messages.py} +44 -0
- services/protocol/conversation.py +586 -0
- services/protocol/openai_v1_chat_complete.py +184 -0
- services/protocol/openai_v1_image_edit.py +38 -0
- services/protocol/openai_v1_image_generations.py +31 -0
- services/protocol/openai_v1_models.py +26 -0
- services/protocol/openai_v1_response.py +216 -0
- test/test_generations.py +1 -1
- test/test_generations_url.py +1 -1
- test/test_image.py +2 -3
- test/test_v1_chat_completions.py +2 -2
- test/test_v1_images_edits.py +2 -2
- test/test_v1_images_generations.py +2 -2
- test/test_v1_messages.py +86 -0
- test/test_v1_models.py +2 -2
- test/test_v1_responses.py +1 -1
- utils/helper.py +21 -42
- utils/log.py +18 -6
- uv.lock +6 -6
- web/src/app/image/components/image-results.tsx +55 -2
- web/src/app/image/components/image-sidebar.tsx +2 -2
- web/src/app/image/page.tsx +63 -8
- web/src/app/layout.tsx +1 -1
- web/src/app/logs/page.tsx +8 -2
- web/src/app/settings/components/config-card.tsx +19 -0
- web/src/app/settings/store.ts +12 -0
- web/src/lib/api.ts +9 -3
- web/src/lib/request.ts +24 -3
- web/src/store/image-conversations.ts +29 -8
VERSION
CHANGED
|
@@ -1 +1 @@
|
|
| 1 |
-
|
|
|
|
| 1 |
+
1.0.0
|
api/ai.py
CHANGED
|
@@ -1,21 +1,19 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from datetime import datetime
|
| 4 |
-
import time
|
| 5 |
-
|
| 6 |
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
|
| 7 |
from fastapi.concurrency import run_in_threadpool
|
| 8 |
-
from fastapi.responses import StreamingResponse
|
| 9 |
from pydantic import BaseModel, ConfigDict, Field
|
| 10 |
|
| 11 |
-
from api.support import
|
| 12 |
-
from services.
|
| 13 |
-
from services.
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
|
|
|
|
|
|
|
|
|
|
| 17 |
)
|
| 18 |
-
from utils.helper import anthropic_sse_stream, is_image_chat_request, sse_json_stream
|
| 19 |
|
| 20 |
|
| 21 |
class ImageGenerationRequest(BaseModel):
|
|
@@ -55,66 +53,14 @@ class AnthropicMessageRequest(BaseModel):
|
|
| 55 |
stream: bool | None = None
|
| 56 |
|
| 57 |
|
| 58 |
-
def
|
| 59 |
-
return {"key_id": identity.get("id"), "key_name": identity.get("name"), "role": identity.get("role")}
|
| 60 |
-
|
| 61 |
-
|
| 62 |
-
def _collect_urls(value: object) -> list[str]:
|
| 63 |
-
urls: list[str] = []
|
| 64 |
-
if isinstance(value, dict):
|
| 65 |
-
for key, item in value.items():
|
| 66 |
-
if key == "url" and isinstance(item, str):
|
| 67 |
-
urls.append(item)
|
| 68 |
-
elif key == "urls" and isinstance(item, list):
|
| 69 |
-
urls.extend(str(url) for url in item if isinstance(url, str))
|
| 70 |
-
else:
|
| 71 |
-
urls.extend(_collect_urls(item))
|
| 72 |
-
elif isinstance(value, list):
|
| 73 |
-
for item in value:
|
| 74 |
-
urls.extend(_collect_urls(item))
|
| 75 |
-
return urls
|
| 76 |
-
|
| 77 |
-
|
| 78 |
-
def _log_call(summary: str, identity: dict[str, object], endpoint: str, model: str, started: float, result: object = None, status: str = "success") -> None:
|
| 79 |
-
detail = {
|
| 80 |
-
**_identity_detail(identity),
|
| 81 |
-
"endpoint": endpoint,
|
| 82 |
-
"model": model,
|
| 83 |
-
"started_at": datetime.fromtimestamp(started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 84 |
-
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 85 |
-
"duration_ms": int((time.time() - started) * 1000),
|
| 86 |
-
"status": status,
|
| 87 |
-
}
|
| 88 |
-
urls = _collect_urls(result)
|
| 89 |
-
if urls:
|
| 90 |
-
detail["urls"] = urls
|
| 91 |
-
log_service.add(LOG_TYPE_CALL, summary, detail)
|
| 92 |
-
|
| 93 |
-
|
| 94 |
-
def _stream_with_log(items, summary: str, identity: dict[str, object], endpoint: str, model: str, started: float):
|
| 95 |
-
urls: list[str] = []
|
| 96 |
-
failed = False
|
| 97 |
-
try:
|
| 98 |
-
for item in items:
|
| 99 |
-
urls.extend(_collect_urls(item))
|
| 100 |
-
yield item
|
| 101 |
-
except Exception:
|
| 102 |
-
failed = True
|
| 103 |
-
_log_call(summary.replace("结束", "失败"), identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))}, "failed")
|
| 104 |
-
raise
|
| 105 |
-
finally:
|
| 106 |
-
if not failed:
|
| 107 |
-
_log_call(summary, identity, endpoint, model, started, {"urls": list(dict.fromkeys(urls))})
|
| 108 |
-
|
| 109 |
-
|
| 110 |
-
def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
|
| 111 |
router = APIRouter()
|
| 112 |
|
| 113 |
@router.get("/v1/models")
|
| 114 |
async def list_models(authorization: str | None = Header(default=None)):
|
| 115 |
require_identity(authorization)
|
| 116 |
try:
|
| 117 |
-
return await run_in_threadpool(
|
| 118 |
except Exception as exc:
|
| 119 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 120 |
|
|
@@ -125,37 +71,10 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
|
|
| 125 |
authorization: str | None = Header(default=None),
|
| 126 |
):
|
| 127 |
identity = require_identity(authorization)
|
| 128 |
-
|
| 129 |
-
base_url = resolve_image_base_url(request)
|
| 130 |
-
|
| 131 |
-
|
| 132 |
-
await run_in_threadpool(account_service.get_available_access_token)
|
| 133 |
-
except RuntimeError as exc:
|
| 134 |
-
raise_image_quota_error(exc)
|
| 135 |
-
return StreamingResponse(
|
| 136 |
-
sse_json_stream(
|
| 137 |
-
_stream_with_log(
|
| 138 |
-
chatgpt_service.stream_image_generation(
|
| 139 |
-
body.prompt, body.model, body.n, body.size, body.response_format, base_url
|
| 140 |
-
),
|
| 141 |
-
"文生图流式调用结束",
|
| 142 |
-
identity,
|
| 143 |
-
"/v1/images/generations",
|
| 144 |
-
body.model,
|
| 145 |
-
started,
|
| 146 |
-
)
|
| 147 |
-
),
|
| 148 |
-
media_type="text/event-stream",
|
| 149 |
-
)
|
| 150 |
-
try:
|
| 151 |
-
result = await run_in_threadpool(
|
| 152 |
-
chatgpt_service.generate_with_pool, body.prompt, body.model, body.n, body.size, body.response_format, base_url
|
| 153 |
-
)
|
| 154 |
-
_log_call("文生图调用完成", identity, "/v1/images/generations", body.model, started, result)
|
| 155 |
-
return result
|
| 156 |
-
except ImageGenerationError as exc:
|
| 157 |
-
_log_call("文生图调用失败", identity, "/v1/images/generations", body.model, started, {"error": str(exc)}, "failed")
|
| 158 |
-
raise_image_quota_error(exc)
|
| 159 |
|
| 160 |
@router.post("/v1/images/edits")
|
| 161 |
async def edit_images(
|
|
@@ -171,85 +90,45 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
|
|
| 171 |
stream: bool | None = Form(default=None),
|
| 172 |
):
|
| 173 |
identity = require_identity(authorization)
|
| 174 |
-
started = time.time()
|
| 175 |
if n < 1 or n > 4:
|
| 176 |
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 177 |
uploads = [*(image or []), *(image_list or [])]
|
| 178 |
if not uploads:
|
| 179 |
raise HTTPException(status_code=400, detail={"error": "image file is required"})
|
| 180 |
-
base_url = resolve_image_base_url(request)
|
| 181 |
images: list[tuple[bytes, str, str]] = []
|
| 182 |
for upload in uploads:
|
| 183 |
image_data = await upload.read()
|
| 184 |
if not image_data:
|
| 185 |
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 186 |
images.append((image_data, upload.filename or "image.png", upload.content_type or "image/png"))
|
| 187 |
-
|
| 188 |
-
|
| 189 |
-
|
| 190 |
-
|
| 191 |
-
|
| 192 |
-
|
| 193 |
-
|
| 194 |
-
|
| 195 |
-
|
| 196 |
-
|
| 197 |
-
|
| 198 |
-
|
| 199 |
-
media_type="text/event-stream",
|
| 200 |
-
)
|
| 201 |
-
try:
|
| 202 |
-
result = await run_in_threadpool(
|
| 203 |
-
chatgpt_service.edit_with_pool, prompt, images, model, n, size, response_format, base_url
|
| 204 |
-
)
|
| 205 |
-
_log_call("图生图调用完成", identity, "/v1/images/edits", model, started, result)
|
| 206 |
-
return result
|
| 207 |
-
except ImageGenerationError as exc:
|
| 208 |
-
_log_call("图生图调用失败", identity, "/v1/images/edits", model, started, {"error": str(exc)}, "failed")
|
| 209 |
-
raise_image_quota_error(exc)
|
| 210 |
|
| 211 |
@router.post("/v1/chat/completions")
|
| 212 |
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
| 213 |
identity = require_identity(authorization)
|
| 214 |
-
started = time.time()
|
| 215 |
payload = body.model_dump(mode="python")
|
| 216 |
model = str(payload.get("model") or "auto")
|
| 217 |
-
|
| 218 |
-
|
| 219 |
-
try:
|
| 220 |
-
await run_in_threadpool(account_service.get_available_access_token)
|
| 221 |
-
except RuntimeError as exc:
|
| 222 |
-
raise_image_quota_error(exc)
|
| 223 |
-
return StreamingResponse(
|
| 224 |
-
sse_json_stream(_stream_with_log(chatgpt_service.stream_chat_completion(payload), "文本生成流式调用结束", identity, "/v1/chat/completions", model, started)),
|
| 225 |
-
media_type="text/event-stream",
|
| 226 |
-
)
|
| 227 |
-
try:
|
| 228 |
-
result = await run_in_threadpool(chatgpt_service.create_chat_completion, payload)
|
| 229 |
-
_log_call("文本生成调用完成", identity, "/v1/chat/completions", model, started, result)
|
| 230 |
-
return result
|
| 231 |
-
except Exception as exc:
|
| 232 |
-
_log_call("文本生成调用失败", identity, "/v1/chat/completions", model, started, {"error": str(exc)}, "failed")
|
| 233 |
-
raise
|
| 234 |
|
| 235 |
@router.post("/v1/responses")
|
| 236 |
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
| 237 |
identity = require_identity(authorization)
|
| 238 |
-
started = time.time()
|
| 239 |
payload = body.model_dump(mode="python")
|
| 240 |
model = str(payload.get("model") or "auto")
|
| 241 |
-
|
| 242 |
-
|
| 243 |
-
sse_json_stream(_stream_with_log(chatgpt_service.stream_response(payload), "Responses 流式调用结束", identity, "/v1/responses", model, started)),
|
| 244 |
-
media_type="text/event-stream",
|
| 245 |
-
)
|
| 246 |
-
try:
|
| 247 |
-
result = await run_in_threadpool(chatgpt_service.create_response, payload)
|
| 248 |
-
_log_call("Responses 调用完成", identity, "/v1/responses", model, started, result)
|
| 249 |
-
return result
|
| 250 |
-
except Exception as exc:
|
| 251 |
-
_log_call("Responses 调用失败", identity, "/v1/responses", model, started, {"error": str(exc)}, "failed")
|
| 252 |
-
raise
|
| 253 |
|
| 254 |
@router.post("/v1/messages")
|
| 255 |
async def create_message(
|
|
@@ -259,20 +138,9 @@ def create_router(chatgpt_service: ChatGPTService) -> APIRouter:
|
|
| 259 |
anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
|
| 260 |
):
|
| 261 |
identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
|
| 262 |
-
started = time.time()
|
| 263 |
payload = body.model_dump(mode="python")
|
| 264 |
model = str(payload.get("model") or "auto")
|
| 265 |
-
|
| 266 |
-
|
| 267 |
-
anthropic_sse_stream(_stream_with_log(chatgpt_service.stream_message(payload), "Messages 流式调用结束", identity, "/v1/messages", model, started)),
|
| 268 |
-
media_type="text/event-stream",
|
| 269 |
-
)
|
| 270 |
-
try:
|
| 271 |
-
result = await run_in_threadpool(chatgpt_service.create_message, payload)
|
| 272 |
-
_log_call("Messages 调用完成", identity, "/v1/messages", model, started, result)
|
| 273 |
-
return result
|
| 274 |
-
except Exception as exc:
|
| 275 |
-
_log_call("Messages 调用失败", identity, "/v1/messages", model, started, {"error": str(exc)}, "failed")
|
| 276 |
-
raise
|
| 277 |
|
| 278 |
return router
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
|
|
|
|
|
|
|
|
|
| 3 |
from fastapi import APIRouter, File, Form, Header, HTTPException, Request, UploadFile
|
| 4 |
from fastapi.concurrency import run_in_threadpool
|
|
|
|
| 5 |
from pydantic import BaseModel, ConfigDict, Field
|
| 6 |
|
| 7 |
+
from api.support import require_identity, resolve_image_base_url
|
| 8 |
+
from services.log_service import LoggedCall
|
| 9 |
+
from services.protocol import (
|
| 10 |
+
anthropic_v1_messages,
|
| 11 |
+
openai_v1_chat_complete,
|
| 12 |
+
openai_v1_image_edit,
|
| 13 |
+
openai_v1_image_generations,
|
| 14 |
+
openai_v1_models,
|
| 15 |
+
openai_v1_response,
|
| 16 |
)
|
|
|
|
| 17 |
|
| 18 |
|
| 19 |
class ImageGenerationRequest(BaseModel):
|
|
|
|
| 53 |
stream: bool | None = None
|
| 54 |
|
| 55 |
|
| 56 |
+
def create_router() -> APIRouter:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 57 |
router = APIRouter()
|
| 58 |
|
| 59 |
@router.get("/v1/models")
|
| 60 |
async def list_models(authorization: str | None = Header(default=None)):
|
| 61 |
require_identity(authorization)
|
| 62 |
try:
|
| 63 |
+
return await run_in_threadpool(openai_v1_models.list_models)
|
| 64 |
except Exception as exc:
|
| 65 |
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 66 |
|
|
|
|
| 71 |
authorization: str | None = Header(default=None),
|
| 72 |
):
|
| 73 |
identity = require_identity(authorization)
|
| 74 |
+
payload = body.model_dump(mode="python")
|
| 75 |
+
payload["base_url"] = resolve_image_base_url(request)
|
| 76 |
+
call = LoggedCall(identity, "/v1/images/generations", body.model, "文生图")
|
| 77 |
+
return await call.run(openai_v1_image_generations.handle, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
|
| 79 |
@router.post("/v1/images/edits")
|
| 80 |
async def edit_images(
|
|
|
|
| 90 |
stream: bool | None = Form(default=None),
|
| 91 |
):
|
| 92 |
identity = require_identity(authorization)
|
|
|
|
| 93 |
if n < 1 or n > 4:
|
| 94 |
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
| 95 |
uploads = [*(image or []), *(image_list or [])]
|
| 96 |
if not uploads:
|
| 97 |
raise HTTPException(status_code=400, detail={"error": "image file is required"})
|
|
|
|
| 98 |
images: list[tuple[bytes, str, str]] = []
|
| 99 |
for upload in uploads:
|
| 100 |
image_data = await upload.read()
|
| 101 |
if not image_data:
|
| 102 |
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
| 103 |
images.append((image_data, upload.filename or "image.png", upload.content_type or "image/png"))
|
| 104 |
+
payload = {
|
| 105 |
+
"prompt": prompt,
|
| 106 |
+
"images": images,
|
| 107 |
+
"model": model,
|
| 108 |
+
"n": n,
|
| 109 |
+
"size": size,
|
| 110 |
+
"response_format": response_format,
|
| 111 |
+
"stream": stream,
|
| 112 |
+
"base_url": resolve_image_base_url(request),
|
| 113 |
+
}
|
| 114 |
+
call = LoggedCall(identity, "/v1/images/edits", model, "图生图")
|
| 115 |
+
return await call.run(openai_v1_image_edit.handle, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 116 |
|
| 117 |
@router.post("/v1/chat/completions")
|
| 118 |
async def create_chat_completion(body: ChatCompletionRequest, authorization: str | None = Header(default=None)):
|
| 119 |
identity = require_identity(authorization)
|
|
|
|
| 120 |
payload = body.model_dump(mode="python")
|
| 121 |
model = str(payload.get("model") or "auto")
|
| 122 |
+
call = LoggedCall(identity, "/v1/chat/completions", model, "文本生成")
|
| 123 |
+
return await call.run(openai_v1_chat_complete.handle, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 124 |
|
| 125 |
@router.post("/v1/responses")
|
| 126 |
async def create_response(body: ResponseCreateRequest, authorization: str | None = Header(default=None)):
|
| 127 |
identity = require_identity(authorization)
|
|
|
|
| 128 |
payload = body.model_dump(mode="python")
|
| 129 |
model = str(payload.get("model") or "auto")
|
| 130 |
+
call = LoggedCall(identity, "/v1/responses", model, "Responses")
|
| 131 |
+
return await call.run(openai_v1_response.handle, payload)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 132 |
|
| 133 |
@router.post("/v1/messages")
|
| 134 |
async def create_message(
|
|
|
|
| 138 |
anthropic_version: str | None = Header(default=None, alias="anthropic-version"),
|
| 139 |
):
|
| 140 |
identity = require_identity(authorization or (f"Bearer {x_api_key}" if x_api_key else None))
|
|
|
|
| 141 |
payload = body.model_dump(mode="python")
|
| 142 |
model = str(payload.get("model") or "auto")
|
| 143 |
+
call = LoggedCall(identity, "/v1/messages", model, "Messages")
|
| 144 |
+
return await call.run(anthropic_v1_messages.handle, payload, sse="anthropic")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
|
| 146 |
return router
|
api/app.py
CHANGED
|
@@ -10,13 +10,10 @@ from fastapi.staticfiles import StaticFiles
|
|
| 10 |
|
| 11 |
from api import accounts, ai, system
|
| 12 |
from api.support import resolve_web_asset, start_limited_account_watcher
|
| 13 |
-
from services.account_service import account_service
|
| 14 |
-
from services.chatgpt_service import ChatGPTService
|
| 15 |
from services.config import config
|
| 16 |
|
| 17 |
|
| 18 |
def create_app() -> FastAPI:
|
| 19 |
-
chatgpt_service = ChatGPTService(account_service)
|
| 20 |
app_version = config.app_version
|
| 21 |
|
| 22 |
@asynccontextmanager
|
|
@@ -38,7 +35,7 @@ def create_app() -> FastAPI:
|
|
| 38 |
allow_methods=["*"],
|
| 39 |
allow_headers=["*"],
|
| 40 |
)
|
| 41 |
-
app.include_router(ai.create_router(
|
| 42 |
app.include_router(accounts.create_router())
|
| 43 |
app.include_router(system.create_router(app_version))
|
| 44 |
if config.images_dir.exists():
|
|
|
|
| 10 |
|
| 11 |
from api import accounts, ai, system
|
| 12 |
from api.support import resolve_web_asset, start_limited_account_watcher
|
|
|
|
|
|
|
| 13 |
from services.config import config
|
| 14 |
|
| 15 |
|
| 16 |
def create_app() -> FastAPI:
|
|
|
|
| 17 |
app_version = config.app_version
|
| 18 |
|
| 19 |
@asynccontextmanager
|
|
|
|
| 35 |
allow_methods=["*"],
|
| 36 |
allow_headers=["*"],
|
| 37 |
)
|
| 38 |
+
app.include_router(ai.create_router())
|
| 39 |
app.include_router(accounts.create_router())
|
| 40 |
app.include_router(system.create_router(app_version))
|
| 41 |
if config.images_dir.exists():
|
config.json
CHANGED
|
@@ -3,6 +3,12 @@
|
|
| 3 |
"refresh_account_interval_minute": 60,
|
| 4 |
"image_retention_days": 15,
|
| 5 |
"auto_remove_invalid_accounts": false,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
"proxy": "",
|
| 7 |
"base_url": ""
|
| 8 |
}
|
|
|
|
| 3 |
"refresh_account_interval_minute": 60,
|
| 4 |
"image_retention_days": 15,
|
| 5 |
"auto_remove_invalid_accounts": false,
|
| 6 |
+
"log_levels": [
|
| 7 |
+
"debug",
|
| 8 |
+
"error",
|
| 9 |
+
"info",
|
| 10 |
+
"warning"
|
| 11 |
+
],
|
| 12 |
"proxy": "",
|
| 13 |
"base_url": ""
|
| 14 |
}
|
docs/upstream-sse-conversation.md
ADDED
|
@@ -0,0 +1,271 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# 上游 Conversation SSE 协议说明
|
| 2 |
+
|
| 3 |
+
Conversation SSE 是上游对话链路的流式返回协议。每条 SSE `data:` 通常是一段 JSON payload,也可能是协议标记或结束标记。客户端需要按顺序消费这些 payload,维护当前会话状态、文本内容、工具调用状态和图片结果指针。
|
| 4 |
+
|
| 5 |
+
## 基本形态
|
| 6 |
+
|
| 7 |
+
常见 payload 示例:
|
| 8 |
+
|
| 9 |
+
```text
|
| 10 |
+
"v1"
|
| 11 |
+
{"type":"resume_conversation_token",...}
|
| 12 |
+
{"p":"","o":"add","v":{...}}
|
| 13 |
+
{"v":{...}}
|
| 14 |
+
{"p":"/message/content/parts/0","o":"append","v":"..."}
|
| 15 |
+
{"type":"server_ste_metadata","metadata":{...}}
|
| 16 |
+
[DONE]
|
| 17 |
+
```
|
| 18 |
+
|
| 19 |
+
处理建议:
|
| 20 |
+
|
| 21 |
+
| payload | 含义 | 处理方式 |
|
| 22 |
+
|:--|:--|:--|
|
| 23 |
+
| `"v1"` | 协议版本标记 | 可记录,通常不影响业务 |
|
| 24 |
+
| `[DONE]` | 当前 SSE 流结束 | 停止继续读取 |
|
| 25 |
+
| JSON object | 事件、消息或 patch | 按字段更新会话状态 |
|
| 26 |
+
| JSON string | 短文本 patch 或协议标记 | 结合上下文处理 |
|
| 27 |
+
| 非 JSON 内容 | 原始内容 | 保留为 raw 事件,避免中断流 |
|
| 28 |
+
|
| 29 |
+
## 常用字段
|
| 30 |
+
|
| 31 |
+
| 字段 | 说明 |
|
| 32 |
+
|:--|:--|
|
| 33 |
+
| `type` | 上游事件类型,如 `resume_conversation_token`、`input_message`、`message_marker`、`title_generation`、`server_ste_metadata` |
|
| 34 |
+
| `conversation_id` | 当前会话 ID,可从多个事件中获得 |
|
| 35 |
+
| `p` | patch 路径,例如 `/message/content/parts/0` |
|
| 36 |
+
| `o` | patch 操作,例如 `add`、`append`、`replace`、`patch` |
|
| 37 |
+
| `v` | patch 值,可能是字符串、数组,也可能包含完整 message |
|
| 38 |
+
| `c` | 消息序号或游标,常见于 add 类事件 |
|
| 39 |
+
| `message.id` | 消息 ID |
|
| 40 |
+
| `message.author.role` | 消息角色,常见 `system`、`user`、`assistant`、`tool` |
|
| 41 |
+
| `message.content.content_type` | 内容类型,如 `text`、`multimodal_text`、`model_editable_context` |
|
| 42 |
+
| `message.content.parts` | 内容片段,可能包含文本、图片指针或多模态对象 |
|
| 43 |
+
| `message.status` | 消息状态,如 `in_progress`、`finished_successfully` |
|
| 44 |
+
| `message.end_turn` | 是否结束当前轮次 |
|
| 45 |
+
| `metadata.tool_invoked` | 本轮是否调用工具 |
|
| 46 |
+
| `metadata.turn_use_case` | 本轮用途,如 `text`、`multimodal` |
|
| 47 |
+
| `metadata.async_task_type` | 异步工具任务类型,图片生成通常为 `image_gen` |
|
| 48 |
+
|
| 49 |
+
## 会话启动事件
|
| 50 |
+
|
| 51 |
+
上游通常会先返回恢复令牌或会话令牌:
|
| 52 |
+
|
| 53 |
+
```json
|
| 54 |
+
{
|
| 55 |
+
"type": "resume_conversation_token",
|
| 56 |
+
"kind": "topic",
|
| 57 |
+
"token": "...",
|
| 58 |
+
"conversation_id": "..."
|
| 59 |
+
}
|
| 60 |
+
```
|
| 61 |
+
|
| 62 |
+
这个事件主要用于标识会话和恢复上下文。业务层通常只需要保存 `conversation_id`,`token` 不应该暴露给下游用户。
|
| 63 |
+
|
| 64 |
+
## 消息 add 场景
|
| 65 |
+
|
| 66 |
+
完整消息可能通过 `add` 或带 `v.message` 的事件出现:
|
| 67 |
+
|
| 68 |
+
```json
|
| 69 |
+
{
|
| 70 |
+
"p": "",
|
| 71 |
+
"o": "add",
|
| 72 |
+
"v": {
|
| 73 |
+
"message": {
|
| 74 |
+
"author": {"role": "assistant"},
|
| 75 |
+
"content": {"content_type": "text", "parts": [""]},
|
| 76 |
+
"status": "in_progress"
|
| 77 |
+
},
|
| 78 |
+
"conversation_id": "..."
|
| 79 |
+
},
|
| 80 |
+
"c": 3
|
| 81 |
+
}
|
| 82 |
+
```
|
| 83 |
+
|
| 84 |
+
此类事件常用于创建一条新消息。若消息角色为 `assistant`,后续文本通常会通过 patch 继续追加。
|
| 85 |
+
|
| 86 |
+
## 文本增量场景
|
| 87 |
+
|
| 88 |
+
文本输出通常由多条 patch 组成:
|
| 89 |
+
|
| 90 |
+
```json
|
| 91 |
+
{"p":"/message/content/parts/0","o":"append","v":"Hello"}
|
| 92 |
+
{"v":" world"}
|
| 93 |
+
{"p":"","o":"patch","v":[
|
| 94 |
+
{"p":"/message/content/parts/0","o":"append","v":"!"},
|
| 95 |
+
{"p":"/message/status","o":"replace","v":"finished_successfully"},
|
| 96 |
+
{"p":"/message/end_turn","o":"replace","v":true}
|
| 97 |
+
]}
|
| 98 |
+
```
|
| 99 |
+
|
| 100 |
+
处理要点:
|
| 101 |
+
|
| 102 |
+
| 形态 | 含义 |
|
| 103 |
+
|:--|:--|
|
| 104 |
+
| `p == "/message/content/parts/0"` 且 `o == "append"` | 向当前文本追加内容 |
|
| 105 |
+
| `o == "replace"` | 用新值替换目标字段 |
|
| 106 |
+
| `o == "patch"` 且 `v` 是数组 | 批量 patch,需要按数组顺序处理 |
|
| 107 |
+
| 只有 `v` 且 `v` 是字符串 | 可能是省略路径的文本增量,应结合当前文本流处理 |
|
| 108 |
+
|
| 109 |
+
## 输入消息场景
|
| 110 |
+
|
| 111 |
+
用户输入会以 `input_message` 或普通 `user` message 出现。图片编辑请求会包含用户上传的参考图:
|
| 112 |
+
|
| 113 |
+
```json
|
| 114 |
+
{
|
| 115 |
+
"type": "input_message",
|
| 116 |
+
"input_message": {
|
| 117 |
+
"author": {"role": "user"},
|
| 118 |
+
"content": {
|
| 119 |
+
"content_type": "multimodal_text",
|
| 120 |
+
"parts": [
|
| 121 |
+
{"asset_pointer": "sediment://file_input"},
|
| 122 |
+
"编辑提示词"
|
| 123 |
+
]
|
| 124 |
+
}
|
| 125 |
+
},
|
| 126 |
+
"conversation_id": "..."
|
| 127 |
+
}
|
| 128 |
+
```
|
| 129 |
+
|
| 130 |
+
这类 `sediment://...` 表示输入附件,不是生成结果。即使它可以被下载,也不能当作输出图片返回。
|
| 131 |
+
|
| 132 |
+
## 图片工具成功场景
|
| 133 |
+
|
| 134 |
+
图片生成或图片编辑成功时,上游一般会出现工具消息:
|
| 135 |
+
|
| 136 |
+
```json
|
| 137 |
+
{
|
| 138 |
+
"v": {
|
| 139 |
+
"message": {
|
| 140 |
+
"author": {"role": "tool"},
|
| 141 |
+
"content": {
|
| 142 |
+
"content_type": "multimodal_text",
|
| 143 |
+
"parts": [
|
| 144 |
+
{"asset_pointer": "file-service://file_result"},
|
| 145 |
+
{"asset_pointer": "sediment://file_result"}
|
| 146 |
+
]
|
| 147 |
+
},
|
| 148 |
+
"metadata": {"async_task_type": "image_gen"}
|
| 149 |
+
}
|
| 150 |
+
},
|
| 151 |
+
"conversation_id": "..."
|
| 152 |
+
}
|
| 153 |
+
```
|
| 154 |
+
|
| 155 |
+
只有同时满足以下条件的图片指针,才应该视为输出结果:
|
| 156 |
+
|
| 157 |
+
| 条件 | 说明 |
|
| 158 |
+
|:--|:--|
|
| 159 |
+
| `message.author.role == "tool"` | 来源是工具消息 |
|
| 160 |
+
| `metadata.async_task_type == "image_gen"` | 工具任务是图片生成 |
|
| 161 |
+
| `asset_pointer` 为 `file-service://...` 或 `sediment://...` | 指向可解析图片资源 |
|
| 162 |
+
|
| 163 |
+
## 图片指针类型
|
| 164 |
+
|
| 165 |
+
| 指针 | 常见来源 | 说明 |
|
| 166 |
+
|:--|:--|:--|
|
| 167 |
+
| `file-service://file_xxx` | 图片工具输出 | 可通过文件下载接口解析 |
|
| 168 |
+
| `sediment://file_xxx` | 输入附件或图片工具输出 | 需要结合消息角色判断来源 |
|
| 169 |
+
| `file_upload` | 上传过程占位 | 通常不应作为输出 |
|
| 170 |
+
|
| 171 |
+
不要只凭字符串里出现 `file_` 或 `sediment://` 就判定为输出图。必须结合消息角色和任务类型。
|
| 172 |
+
|
| 173 |
+
## 策略拒绝场景
|
| 174 |
+
|
| 175 |
+
当上游拒绝请求时,通常不会产生图片工具消息,而是返回普通 assistant 文本:
|
| 176 |
+
|
| 177 |
+
```text
|
| 178 |
+
I can't assist with that request. If you have another type of modification...
|
| 179 |
+
```
|
| 180 |
+
|
| 181 |
+
常见伴随事件:
|
| 182 |
+
|
| 183 |
+
```json
|
| 184 |
+
{"type":"title_generation","title":"Request Denied","conversation_id":"..."}
|
| 185 |
+
```
|
| 186 |
+
|
| 187 |
+
```json
|
| 188 |
+
{
|
| 189 |
+
"type": "server_ste_metadata",
|
| 190 |
+
"metadata": {
|
| 191 |
+
"tool_invoked": false,
|
| 192 |
+
"turn_use_case": "multimodal",
|
| 193 |
+
"did_prompt_contain_image": true
|
| 194 |
+
},
|
| 195 |
+
"conversation_id": "..."
|
| 196 |
+
}
|
| 197 |
+
```
|
| 198 |
+
|
| 199 |
+
处理要点:
|
| 200 |
+
|
| 201 |
+
| 条件 | 行为 |
|
| 202 |
+
|:--|:--|
|
| 203 |
+
| 有 assistant 拒绝文本 | 应返回文本消息 |
|
| 204 |
+
| `tool_invoked == false` | 说明没有实际工具结果 |
|
| 205 |
+
| 没有 `role=tool` 且 `async_task_type=image_gen` 的消息 | 不应收集输出图片 |
|
| 206 |
+
| 用户输入消息里有图片指针 | 仍然只视为输入附件 |
|
| 207 |
+
|
| 208 |
+
## moderation 场景
|
| 209 |
+
|
| 210 |
+
部分请求可能返回 moderation 事件:
|
| 211 |
+
|
| 212 |
+
```json
|
| 213 |
+
{
|
| 214 |
+
"type": "moderation",
|
| 215 |
+
"moderation_response": {
|
| 216 |
+
"blocked": true
|
| 217 |
+
},
|
| 218 |
+
"conversation_id": "..."
|
| 219 |
+
}
|
| 220 |
+
```
|
| 221 |
+
|
| 222 |
+
若 `blocked == true`,应认为本轮被策略拦截。后续如有 assistant 文本,应优先返回该文本;若没有文本,可返回合适的错误信息。
|
| 223 |
+
|
| 224 |
+
## marker 和 title 事件
|
| 225 |
+
|
| 226 |
+
上游会返回一些辅助事件:
|
| 227 |
+
|
| 228 |
+
```json
|
| 229 |
+
{"type":"message_marker","marker":"user_visible_token","event":"first"}
|
| 230 |
+
{"type":"message_marker","marker":"last_token","event":"last"}
|
| 231 |
+
{"type":"title_generation","title":"...","conversation_id":"..."}
|
| 232 |
+
```
|
| 233 |
+
|
| 234 |
+
这些事件通常用于前端展示、标题生成或流式状态标记,不代表实际文本内容或图片结果。
|
| 235 |
+
|
| 236 |
+
## metadata 事件
|
| 237 |
+
|
| 238 |
+
`server_ste_metadata` 用于描述本轮调度和工具状态:
|
| 239 |
+
|
| 240 |
+
```json
|
| 241 |
+
{
|
| 242 |
+
"type": "server_ste_metadata",
|
| 243 |
+
"metadata": {
|
| 244 |
+
"tool_invoked": true,
|
| 245 |
+
"turn_use_case": "multimodal",
|
| 246 |
+
"model_slug": "i-mini-m",
|
| 247 |
+
"did_prompt_contain_image": true
|
| 248 |
+
}
|
| 249 |
+
}
|
| 250 |
+
```
|
| 251 |
+
|
| 252 |
+
常用判断:
|
| 253 |
+
|
| 254 |
+
| 字段 | 说明 |
|
| 255 |
+
|:--|:--|
|
| 256 |
+
| `tool_invoked == true` | 上游认为本轮调用过工具 |
|
| 257 |
+
| `tool_invoked == false` | 上游未调用工具,常见于拒绝或纯文本响应 |
|
| 258 |
+
| `turn_use_case == "text"` | 按文本响应处理 |
|
| 259 |
+
| `turn_use_case == "multimodal"` | 多模态请求,不代表一定有图片输出 |
|
| 260 |
+
| `did_prompt_contain_image == true` | 输入包含图片,不代表输出包含图片 |
|
| 261 |
+
|
| 262 |
+
## 结束后的结果判断
|
| 263 |
+
|
| 264 |
+
SSE 结束后可按以下顺序判断结果:
|
| 265 |
+
|
| 266 |
+
1. 如果已经收集到图片工具输出指针,解析并下载输出图片。
|
| 267 |
+
2. 如果没有输出图片指针,但有 assistant 文本,并且本轮被拦截或未调用工具,返回文本消息。
|
| 268 |
+
3. 如果没有输出图片指针,但有 `conversation_id`,可查询完整会话明细,继续寻找图片工具输出。
|
| 269 |
+
4. 查询完整会话时,仍然只读取 `role=tool` 且 `async_task_type=image_gen` 的消息。
|
| 270 |
+
5. 如果没有图片结果也没有文本,返回上游异常或空结果错误。
|
| 271 |
+
|
services/account_service.py
CHANGED
|
@@ -4,7 +4,6 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
| 4 |
import base64
|
| 5 |
import hashlib
|
| 6 |
import json
|
| 7 |
-
from pathlib import Path
|
| 8 |
from threading import Lock
|
| 9 |
from typing import Any
|
| 10 |
from datetime import datetime
|
|
@@ -252,8 +251,7 @@ class AccountService:
|
|
| 252 |
message = str(exc)
|
| 253 |
print(f"[account-available] refresh token={token_ref} fail {message}")
|
| 254 |
if "/backend-api/me failed: HTTP 401" in message:
|
| 255 |
-
if
|
| 256 |
-
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_account_state", "token": token_ref})
|
| 257 |
return None
|
| 258 |
return self.update_account(
|
| 259 |
access_token,
|
|
@@ -280,6 +278,22 @@ class AccountService:
|
|
| 280 |
f"status={account.get('status') if account else 'unknown'}"
|
| 281 |
)
|
| 282 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 283 |
def next_token(self) -> str:
|
| 284 |
return self.get_available_access_token()
|
| 285 |
|
|
@@ -505,9 +519,7 @@ class AccountService:
|
|
| 505 |
message = str(exc)
|
| 506 |
print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
|
| 507 |
if "/backend-api/me failed: HTTP 401" in message:
|
| 508 |
-
if
|
| 509 |
-
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": "refresh_accounts", "token": anonymize_token(access_token)})
|
| 510 |
-
else:
|
| 511 |
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 512 |
message = "检测到封号"
|
| 513 |
errors.append({"access_token": access_token, "error": message})
|
|
|
|
| 4 |
import base64
|
| 5 |
import hashlib
|
| 6 |
import json
|
|
|
|
| 7 |
from threading import Lock
|
| 8 |
from typing import Any
|
| 9 |
from datetime import datetime
|
|
|
|
| 251 |
message = str(exc)
|
| 252 |
print(f"[account-available] refresh token={token_ref} fail {message}")
|
| 253 |
if "/backend-api/me failed: HTTP 401" in message:
|
| 254 |
+
if self.remove_invalid_token(access_token, "refresh_account_state"):
|
|
|
|
| 255 |
return None
|
| 256 |
return self.update_account(
|
| 257 |
access_token,
|
|
|
|
| 278 |
f"status={account.get('status') if account else 'unknown'}"
|
| 279 |
)
|
| 280 |
|
| 281 |
+
def get_text_access_token(self) -> str:
|
| 282 |
+
with self._lock:
|
| 283 |
+
for account in self._accounts:
|
| 284 |
+
status = self._clean_token(account.get("status"))
|
| 285 |
+
if status not in {"禁用", "异常"}:
|
| 286 |
+
return self._clean_token(account.get("access_token"))
|
| 287 |
+
return ""
|
| 288 |
+
|
| 289 |
+
def remove_invalid_token(self, access_token: str, event: str) -> bool:
|
| 290 |
+
if not config.auto_remove_invalid_accounts:
|
| 291 |
+
return False
|
| 292 |
+
removed = self.remove_token(access_token)
|
| 293 |
+
if removed:
|
| 294 |
+
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
|
| 295 |
+
return removed
|
| 296 |
+
|
| 297 |
def next_token(self) -> str:
|
| 298 |
return self.get_available_access_token()
|
| 299 |
|
|
|
|
| 519 |
message = str(exc)
|
| 520 |
print(f"[account-refresh] fail {anonymize_token(access_token)} {message}")
|
| 521 |
if "/backend-api/me failed: HTTP 401" in message:
|
| 522 |
+
if not self.remove_invalid_token(access_token, "refresh_accounts"):
|
|
|
|
|
|
|
| 523 |
self.update_account(access_token, {"status": "异常", "quota": 0})
|
| 524 |
message = "检测到封号"
|
| 525 |
errors.append({"access_token": access_token, "error": message})
|
services/chatgpt_service.py
DELETED
|
@@ -1,1178 +0,0 @@
|
|
| 1 |
-
from __future__ import annotations
|
| 2 |
-
|
| 3 |
-
import base64
|
| 4 |
-
import hashlib
|
| 5 |
-
import re
|
| 6 |
-
import time
|
| 7 |
-
import uuid
|
| 8 |
-
from pathlib import Path
|
| 9 |
-
from typing import Any, Iterable, Iterator
|
| 10 |
-
|
| 11 |
-
from fastapi import HTTPException
|
| 12 |
-
|
| 13 |
-
from services.account_service import AccountService
|
| 14 |
-
from services.anthropic_protocol import preprocess_payload
|
| 15 |
-
from services.config import config
|
| 16 |
-
from services.log_service import LOG_TYPE_ACCOUNT, log_service
|
| 17 |
-
from services.openai_backend_api import CODEX_IMAGE_MODEL, OpenAIBackendAPI
|
| 18 |
-
from utils.helper import (
|
| 19 |
-
IMAGE_MODELS,
|
| 20 |
-
anonymize_token,
|
| 21 |
-
extract_chat_image,
|
| 22 |
-
extract_chat_prompt,
|
| 23 |
-
extract_image_from_message_content,
|
| 24 |
-
extract_response_prompt,
|
| 25 |
-
has_response_image_generation_tool,
|
| 26 |
-
parse_image_count,
|
| 27 |
-
build_chat_image_completion,
|
| 28 |
-
)
|
| 29 |
-
from utils.helper import is_image_chat_request
|
| 30 |
-
from utils.log import logger
|
| 31 |
-
|
| 32 |
-
|
| 33 |
-
class ImageGenerationError(Exception):
|
| 34 |
-
pass
|
| 35 |
-
|
| 36 |
-
|
| 37 |
-
def is_token_invalid_error(message: str) -> bool:
|
| 38 |
-
text = str(message or "").lower()
|
| 39 |
-
return (
|
| 40 |
-
"token_invalidated" in text
|
| 41 |
-
or "token_revoked" in text
|
| 42 |
-
or "authentication token has been invalidated" in text
|
| 43 |
-
or "invalidated oauth token" in text
|
| 44 |
-
)
|
| 45 |
-
|
| 46 |
-
|
| 47 |
-
def _save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
|
| 48 |
-
config.cleanup_old_images()
|
| 49 |
-
file_hash = hashlib.md5(image_data).hexdigest()
|
| 50 |
-
timestamp = int(time.time())
|
| 51 |
-
filename = f"{timestamp}_{file_hash}.png"
|
| 52 |
-
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
| 53 |
-
file_path = config.images_dir / relative_dir / filename
|
| 54 |
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 55 |
-
file_path.write_bytes(image_data)
|
| 56 |
-
return f"{(base_url or config.base_url)}/images/{relative_dir.as_posix()}/{filename}"
|
| 57 |
-
|
| 58 |
-
|
| 59 |
-
def _extract_response_image(input_value: object) -> tuple[bytes, str] | None:
|
| 60 |
-
if isinstance(input_value, dict):
|
| 61 |
-
return extract_image_from_message_content(input_value.get("content"))
|
| 62 |
-
if not isinstance(input_value, list):
|
| 63 |
-
return None
|
| 64 |
-
for item in reversed(input_value):
|
| 65 |
-
if isinstance(item, dict):
|
| 66 |
-
if str(item.get("type") or "").strip() == "input_image":
|
| 67 |
-
import base64 as b64
|
| 68 |
-
image_url = str(item.get("image_url") or "")
|
| 69 |
-
if image_url.startswith("data:"):
|
| 70 |
-
header, _, data = image_url.partition(",")
|
| 71 |
-
mime = header.split(";")[0].removeprefix("data:")
|
| 72 |
-
return b64.b64decode(data), mime or "image/png"
|
| 73 |
-
content = item.get("content")
|
| 74 |
-
if content:
|
| 75 |
-
result = extract_image_from_message_content(content)
|
| 76 |
-
if result:
|
| 77 |
-
return result
|
| 78 |
-
return None
|
| 79 |
-
|
| 80 |
-
|
| 81 |
-
class ChatGPTService:
|
| 82 |
-
def __init__(self, account_service: AccountService):
|
| 83 |
-
self.account_service = account_service
|
| 84 |
-
|
| 85 |
-
@staticmethod
|
| 86 |
-
def _new_backend(access_token: str = "") -> OpenAIBackendAPI:
|
| 87 |
-
return OpenAIBackendAPI(access_token=access_token)
|
| 88 |
-
|
| 89 |
-
def _get_text_access_token(self) -> str:
|
| 90 |
-
try:
|
| 91 |
-
for access_token in self.account_service.list_tokens():
|
| 92 |
-
status = str((self.account_service.get_account(access_token) or {}).get("status") or "").strip()
|
| 93 |
-
if status not in {"禁用", "异常"}:
|
| 94 |
-
return access_token
|
| 95 |
-
except Exception:
|
| 96 |
-
pass
|
| 97 |
-
return ""
|
| 98 |
-
|
| 99 |
-
@staticmethod
|
| 100 |
-
def _encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
|
| 101 |
-
encoded_images: list[str] = []
|
| 102 |
-
for image_data, _, _ in images:
|
| 103 |
-
if image_data:
|
| 104 |
-
encoded_images.append(base64.b64encode(image_data).decode("ascii"))
|
| 105 |
-
return encoded_images
|
| 106 |
-
|
| 107 |
-
def list_models(self) -> dict[str, object]:
|
| 108 |
-
result = self._new_backend().list_models()
|
| 109 |
-
data = result.get("data")
|
| 110 |
-
if not isinstance(data, list):
|
| 111 |
-
return result
|
| 112 |
-
seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
|
| 113 |
-
for model in sorted(IMAGE_MODELS):
|
| 114 |
-
if model in seen:
|
| 115 |
-
continue
|
| 116 |
-
data.append({
|
| 117 |
-
"id": model,
|
| 118 |
-
"object": "model",
|
| 119 |
-
"created": 0,
|
| 120 |
-
"owned_by": "chatgpt2api",
|
| 121 |
-
"permission": [],
|
| 122 |
-
"root": model,
|
| 123 |
-
"parent": None,
|
| 124 |
-
})
|
| 125 |
-
return result
|
| 126 |
-
|
| 127 |
-
@staticmethod
|
| 128 |
-
def _chat_messages_from_body(body: dict[str, object]) -> list[dict[str, object]]:
|
| 129 |
-
messages = body.get("messages")
|
| 130 |
-
if isinstance(messages, list) and messages:
|
| 131 |
-
return [message for message in messages if isinstance(message, dict)]
|
| 132 |
-
prompt = str(body.get("prompt") or "").strip()
|
| 133 |
-
if prompt:
|
| 134 |
-
return [{"role": "user", "content": prompt}]
|
| 135 |
-
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
|
| 136 |
-
|
| 137 |
-
@staticmethod
|
| 138 |
-
def _response_messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, object]]:
|
| 139 |
-
messages: list[dict[str, object]] = []
|
| 140 |
-
system_text = str(instructions or "").strip()
|
| 141 |
-
if system_text:
|
| 142 |
-
messages.append({"role": "system", "content": system_text})
|
| 143 |
-
|
| 144 |
-
if isinstance(input_value, str):
|
| 145 |
-
user_text = input_value.strip()
|
| 146 |
-
if user_text:
|
| 147 |
-
messages.append({"role": "user", "content": user_text})
|
| 148 |
-
return messages
|
| 149 |
-
|
| 150 |
-
if isinstance(input_value, dict):
|
| 151 |
-
messages.append({
|
| 152 |
-
"role": str(input_value.get("role") or "user"),
|
| 153 |
-
"content": extract_response_prompt([input_value]) or input_value.get("content") or "",
|
| 154 |
-
})
|
| 155 |
-
return messages
|
| 156 |
-
|
| 157 |
-
if isinstance(input_value, list):
|
| 158 |
-
if all(isinstance(item, dict) and item.get("type") for item in input_value):
|
| 159 |
-
text = extract_response_prompt(input_value)
|
| 160 |
-
if text:
|
| 161 |
-
messages.append({"role": "user", "content": text})
|
| 162 |
-
return messages
|
| 163 |
-
for item in input_value:
|
| 164 |
-
if not isinstance(item, dict):
|
| 165 |
-
continue
|
| 166 |
-
messages.append({
|
| 167 |
-
"role": str(item.get("role") or "user"),
|
| 168 |
-
"content": extract_response_prompt([item]) or item.get("content") or "",
|
| 169 |
-
})
|
| 170 |
-
return messages
|
| 171 |
-
|
| 172 |
-
return messages
|
| 173 |
-
|
| 174 |
-
@staticmethod
|
| 175 |
-
def _response_text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, object]:
|
| 176 |
-
return {
|
| 177 |
-
"id": item_id or f"msg_{uuid.uuid4().hex}",
|
| 178 |
-
"type": "message",
|
| 179 |
-
"status": status,
|
| 180 |
-
"role": "assistant",
|
| 181 |
-
"content": [{
|
| 182 |
-
"type": "output_text",
|
| 183 |
-
"text": text,
|
| 184 |
-
"annotations": [],
|
| 185 |
-
}],
|
| 186 |
-
}
|
| 187 |
-
|
| 188 |
-
def _create_text_response(self, body: dict[str, object]) -> dict[str, object]:
|
| 189 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 190 |
-
messages = self._response_messages_from_input(body.get("input"), body.get("instructions"))
|
| 191 |
-
if len(messages) == 1 and messages[0].get("role") == "system":
|
| 192 |
-
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 193 |
-
try:
|
| 194 |
-
result = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
|
| 195 |
-
except Exception as exc:
|
| 196 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 197 |
-
|
| 198 |
-
created = int(result.get("created") or time.time())
|
| 199 |
-
output_text = str((((result.get("choices") or [{}])[0].get("message") or {}).get("content")) or "")
|
| 200 |
-
response_id = f"resp_{uuid.uuid4().hex}"
|
| 201 |
-
output_item = self._response_text_output_item(output_text)
|
| 202 |
-
return {
|
| 203 |
-
"id": response_id,
|
| 204 |
-
"object": "response",
|
| 205 |
-
"created_at": created,
|
| 206 |
-
"status": "completed",
|
| 207 |
-
"error": None,
|
| 208 |
-
"incomplete_details": None,
|
| 209 |
-
"model": model,
|
| 210 |
-
"output": [output_item],
|
| 211 |
-
"parallel_tool_calls": False,
|
| 212 |
-
"usage": result.get("usage"),
|
| 213 |
-
}
|
| 214 |
-
|
| 215 |
-
def _stream_text_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 216 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 217 |
-
messages = self._response_messages_from_input(body.get("input"), body.get("instructions"))
|
| 218 |
-
if len(messages) == 1 and messages[0].get("role") == "system":
|
| 219 |
-
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 220 |
-
|
| 221 |
-
response_id = f"resp_{uuid.uuid4().hex}"
|
| 222 |
-
item_id = f"msg_{uuid.uuid4().hex}"
|
| 223 |
-
created = int(time.time())
|
| 224 |
-
full_text = ""
|
| 225 |
-
|
| 226 |
-
yield {
|
| 227 |
-
"type": "response.created",
|
| 228 |
-
"response": {
|
| 229 |
-
"id": response_id,
|
| 230 |
-
"object": "response",
|
| 231 |
-
"created_at": created,
|
| 232 |
-
"status": "in_progress",
|
| 233 |
-
"error": None,
|
| 234 |
-
"incomplete_details": None,
|
| 235 |
-
"model": model,
|
| 236 |
-
"output": [],
|
| 237 |
-
"parallel_tool_calls": False,
|
| 238 |
-
},
|
| 239 |
-
}
|
| 240 |
-
yield {
|
| 241 |
-
"type": "response.output_item.added",
|
| 242 |
-
"output_index": 0,
|
| 243 |
-
"item": self._response_text_output_item("", item_id=item_id, status="in_progress"),
|
| 244 |
-
}
|
| 245 |
-
|
| 246 |
-
try:
|
| 247 |
-
stream = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=True)
|
| 248 |
-
for chunk in stream:
|
| 249 |
-
choices = chunk.get("choices")
|
| 250 |
-
first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 251 |
-
delta = first_choice.get("delta") if isinstance(first_choice.get("delta"), dict) else {}
|
| 252 |
-
delta_text = str(delta.get("content") or "")
|
| 253 |
-
if delta_text:
|
| 254 |
-
full_text += delta_text
|
| 255 |
-
yield {
|
| 256 |
-
"type": "response.output_text.delta",
|
| 257 |
-
"item_id": item_id,
|
| 258 |
-
"output_index": 0,
|
| 259 |
-
"content_index": 0,
|
| 260 |
-
"delta": delta_text,
|
| 261 |
-
}
|
| 262 |
-
except Exception as exc:
|
| 263 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 264 |
-
|
| 265 |
-
yield {
|
| 266 |
-
"type": "response.output_text.done",
|
| 267 |
-
"item_id": item_id,
|
| 268 |
-
"output_index": 0,
|
| 269 |
-
"content_index": 0,
|
| 270 |
-
"text": full_text,
|
| 271 |
-
}
|
| 272 |
-
output_item = self._response_text_output_item(full_text, item_id=item_id, status="completed")
|
| 273 |
-
yield {
|
| 274 |
-
"type": "response.output_item.done",
|
| 275 |
-
"output_index": 0,
|
| 276 |
-
"item": output_item,
|
| 277 |
-
}
|
| 278 |
-
yield {
|
| 279 |
-
"type": "response.completed",
|
| 280 |
-
"response": {
|
| 281 |
-
"id": response_id,
|
| 282 |
-
"object": "response",
|
| 283 |
-
"created_at": created,
|
| 284 |
-
"status": "completed",
|
| 285 |
-
"error": None,
|
| 286 |
-
"incomplete_details": None,
|
| 287 |
-
"model": model,
|
| 288 |
-
"output": [output_item],
|
| 289 |
-
"parallel_tool_calls": False,
|
| 290 |
-
},
|
| 291 |
-
}
|
| 292 |
-
|
| 293 |
-
@staticmethod
|
| 294 |
-
def _is_text_response_request(body: dict[str, object]) -> bool:
|
| 295 |
-
tools = body.get("tools")
|
| 296 |
-
if isinstance(tools, list):
|
| 297 |
-
for tool in tools:
|
| 298 |
-
if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
|
| 299 |
-
return False
|
| 300 |
-
tool_choice = body.get("tool_choice")
|
| 301 |
-
if isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation":
|
| 302 |
-
return False
|
| 303 |
-
return True
|
| 304 |
-
|
| 305 |
-
@staticmethod
|
| 306 |
-
def _is_codex_image_response_request(body: dict[str, object]) -> bool:
|
| 307 |
-
return has_response_image_generation_tool(body) and str(body.get("model") or "").strip() == CODEX_IMAGE_MODEL
|
| 308 |
-
|
| 309 |
-
@staticmethod
|
| 310 |
-
def _build_image_response_output(
|
| 311 |
-
prompt: str,
|
| 312 |
-
image_result: dict[str, object],
|
| 313 |
-
) -> list[dict[str, object]]:
|
| 314 |
-
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 315 |
-
output: list[dict[str, object]] = []
|
| 316 |
-
for item in image_items:
|
| 317 |
-
if not isinstance(item, dict):
|
| 318 |
-
continue
|
| 319 |
-
b64_json = str(item.get("b64_json") or "").strip()
|
| 320 |
-
if not b64_json:
|
| 321 |
-
continue
|
| 322 |
-
output.append(
|
| 323 |
-
{
|
| 324 |
-
"id": f"ig_{len(output) + 1}",
|
| 325 |
-
"type": "image_generation_call",
|
| 326 |
-
"status": "completed",
|
| 327 |
-
"result": b64_json,
|
| 328 |
-
"revised_prompt": str(item.get("revised_prompt") or prompt).strip(),
|
| 329 |
-
}
|
| 330 |
-
)
|
| 331 |
-
return output
|
| 332 |
-
|
| 333 |
-
def _create_token_image_response(self, body: dict[str, object]) -> dict[str, object]:
|
| 334 |
-
prompt = extract_response_prompt(body.get("input"))
|
| 335 |
-
if not prompt:
|
| 336 |
-
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 337 |
-
|
| 338 |
-
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 339 |
-
image_info = _extract_response_image(body.get("input"))
|
| 340 |
-
try:
|
| 341 |
-
if image_info:
|
| 342 |
-
image_data, mime_type = image_info
|
| 343 |
-
image_result = self.edit_with_pool(prompt, [(image_data, "image.png", mime_type)], model, 1)
|
| 344 |
-
else:
|
| 345 |
-
image_result = self.generate_with_pool(prompt, model, 1, size="1:1")
|
| 346 |
-
except ImageGenerationError as exc:
|
| 347 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 348 |
-
|
| 349 |
-
output = self._build_image_response_output(prompt, image_result)
|
| 350 |
-
if not output:
|
| 351 |
-
raise HTTPException(status_code=502, detail={"error": "image generation failed"})
|
| 352 |
-
|
| 353 |
-
created = int(image_result.get("created") or time.time())
|
| 354 |
-
response = {
|
| 355 |
-
"id": f"resp_{created}",
|
| 356 |
-
"object": "response",
|
| 357 |
-
"created_at": created,
|
| 358 |
-
"status": "completed",
|
| 359 |
-
"error": None,
|
| 360 |
-
"incomplete_details": None,
|
| 361 |
-
"model": model,
|
| 362 |
-
"output": output,
|
| 363 |
-
"parallel_tool_calls": False,
|
| 364 |
-
}
|
| 365 |
-
return response
|
| 366 |
-
|
| 367 |
-
def _stream_token_image_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 368 |
-
prompt = extract_response_prompt(body.get("input"))
|
| 369 |
-
if not prompt:
|
| 370 |
-
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 371 |
-
|
| 372 |
-
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 373 |
-
image_info = _extract_response_image(body.get("input"))
|
| 374 |
-
response_id = f"resp_{uuid.uuid4().hex}"
|
| 375 |
-
item_id = f"ig_{uuid.uuid4().hex}"
|
| 376 |
-
created = int(time.time())
|
| 377 |
-
final_output: list[dict[str, object]] = []
|
| 378 |
-
|
| 379 |
-
yield {
|
| 380 |
-
"type": "response.created",
|
| 381 |
-
"response": {
|
| 382 |
-
"id": response_id,
|
| 383 |
-
"object": "response",
|
| 384 |
-
"created_at": created,
|
| 385 |
-
"status": "in_progress",
|
| 386 |
-
"error": None,
|
| 387 |
-
"incomplete_details": None,
|
| 388 |
-
"model": model,
|
| 389 |
-
"output": [],
|
| 390 |
-
"parallel_tool_calls": False,
|
| 391 |
-
},
|
| 392 |
-
}
|
| 393 |
-
|
| 394 |
-
try:
|
| 395 |
-
if image_info:
|
| 396 |
-
image_data, mime_type = image_info
|
| 397 |
-
stream = self.stream_image_edit(prompt, [(image_data, "image.png", mime_type)], model, 1)
|
| 398 |
-
else:
|
| 399 |
-
stream = self.stream_image_generation(prompt, model, 1, size="1:1")
|
| 400 |
-
|
| 401 |
-
for chunk in stream:
|
| 402 |
-
data = chunk.get("data")
|
| 403 |
-
if not isinstance(data, list) or not data:
|
| 404 |
-
continue
|
| 405 |
-
output = self._build_image_response_output(
|
| 406 |
-
prompt,
|
| 407 |
-
{
|
| 408 |
-
"created": chunk.get("created"),
|
| 409 |
-
"data": data,
|
| 410 |
-
},
|
| 411 |
-
)
|
| 412 |
-
if output:
|
| 413 |
-
final_output = output
|
| 414 |
-
except ImageGenerationError as exc:
|
| 415 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 416 |
-
|
| 417 |
-
if not final_output:
|
| 418 |
-
raise HTTPException(status_code=502, detail={"error": "image generation failed"})
|
| 419 |
-
|
| 420 |
-
final_item = dict(final_output[0])
|
| 421 |
-
final_item["id"] = item_id
|
| 422 |
-
yield {
|
| 423 |
-
"type": "response.output_item.done",
|
| 424 |
-
"output_index": 0,
|
| 425 |
-
"item": final_item,
|
| 426 |
-
}
|
| 427 |
-
yield {
|
| 428 |
-
"type": "response.completed",
|
| 429 |
-
"response": {
|
| 430 |
-
"id": response_id,
|
| 431 |
-
"object": "response",
|
| 432 |
-
"created_at": created,
|
| 433 |
-
"status": "completed",
|
| 434 |
-
"error": None,
|
| 435 |
-
"incomplete_details": None,
|
| 436 |
-
"model": model,
|
| 437 |
-
"output": [final_item],
|
| 438 |
-
"parallel_tool_calls": False,
|
| 439 |
-
},
|
| 440 |
-
}
|
| 441 |
-
|
| 442 |
-
@staticmethod
|
| 443 |
-
def _format_image_result(
|
| 444 |
-
result: dict[str, object],
|
| 445 |
-
prompt: str,
|
| 446 |
-
response_format: str,
|
| 447 |
-
base_url: str | None = None,
|
| 448 |
-
) -> dict[str, object]:
|
| 449 |
-
created = result.get("created")
|
| 450 |
-
data = result.get("data")
|
| 451 |
-
formatted_items: list[dict[str, object]] = []
|
| 452 |
-
if isinstance(data, list):
|
| 453 |
-
for item in data:
|
| 454 |
-
if not isinstance(item, dict):
|
| 455 |
-
continue
|
| 456 |
-
revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
|
| 457 |
-
b64_json = str(item.get("b64_json") or "").strip()
|
| 458 |
-
if response_format == "b64_json":
|
| 459 |
-
if b64_json:
|
| 460 |
-
image_url = _save_image_bytes(base64.b64decode(b64_json), base_url)
|
| 461 |
-
formatted_items.append({"b64_json": b64_json, "url": image_url, "revised_prompt": revised_prompt})
|
| 462 |
-
continue
|
| 463 |
-
if not b64_json:
|
| 464 |
-
continue
|
| 465 |
-
image_data = base64.b64decode(b64_json)
|
| 466 |
-
formatted_items.append(
|
| 467 |
-
{"url": _save_image_bytes(image_data, base_url), "revised_prompt": revised_prompt})
|
| 468 |
-
return {"created": created, "data": formatted_items}
|
| 469 |
-
|
| 470 |
-
def _remove_invalid_token(self, access_token: str, event: str) -> bool:
|
| 471 |
-
if not config.auto_remove_invalid_accounts:
|
| 472 |
-
return False
|
| 473 |
-
removed = self.account_service.remove_token(access_token)
|
| 474 |
-
if removed:
|
| 475 |
-
log_service.add(LOG_TYPE_ACCOUNT, "自动移除异常账号", {"source": event, "token": anonymize_token(access_token)})
|
| 476 |
-
return removed
|
| 477 |
-
|
| 478 |
-
@staticmethod
|
| 479 |
-
def _extract_image_data_urls(markdown_content: str) -> list[str]:
|
| 480 |
-
return re.findall(r"!\[[^\]]*\]\((data:image/[^;]+;base64,[^)]+)\)", markdown_content or "")
|
| 481 |
-
|
| 482 |
-
def _stream_result_from_markdown(
|
| 483 |
-
self,
|
| 484 |
-
markdown_content: str,
|
| 485 |
-
prompt: str,
|
| 486 |
-
response_format: str,
|
| 487 |
-
base_url: str | None = None,
|
| 488 |
-
created: int | None = None,
|
| 489 |
-
) -> dict[str, object] | None:
|
| 490 |
-
data_urls = self._extract_image_data_urls(markdown_content)
|
| 491 |
-
if not data_urls:
|
| 492 |
-
return None
|
| 493 |
-
raw_items: list[dict[str, object]] = []
|
| 494 |
-
for data_url in data_urls:
|
| 495 |
-
header, _, data = data_url.partition(",")
|
| 496 |
-
mime_type = header.split(";")[0].removeprefix("data:") or "image/png"
|
| 497 |
-
raw_items.append({
|
| 498 |
-
"b64_json": data,
|
| 499 |
-
"revised_prompt": prompt,
|
| 500 |
-
"mime_type": mime_type,
|
| 501 |
-
})
|
| 502 |
-
return self._format_image_result(
|
| 503 |
-
{"created": created or int(time.time()), "data": raw_items},
|
| 504 |
-
prompt,
|
| 505 |
-
response_format,
|
| 506 |
-
base_url,
|
| 507 |
-
)
|
| 508 |
-
|
| 509 |
-
@staticmethod
|
| 510 |
-
def _progress_chunk(
|
| 511 |
-
model: str,
|
| 512 |
-
index: int,
|
| 513 |
-
total: int,
|
| 514 |
-
created: int | None = None,
|
| 515 |
-
progress_text: str = "",
|
| 516 |
-
upstream_event_type: str = "",
|
| 517 |
-
) -> dict[str, object]:
|
| 518 |
-
return {
|
| 519 |
-
"object": "image.generation.chunk",
|
| 520 |
-
"created": created or int(time.time()),
|
| 521 |
-
"model": model,
|
| 522 |
-
"index": index,
|
| 523 |
-
"total": total,
|
| 524 |
-
"progress_text": progress_text,
|
| 525 |
-
"upstream_event_type": upstream_event_type,
|
| 526 |
-
"data": [],
|
| 527 |
-
}
|
| 528 |
-
|
| 529 |
-
def _stream_single_image_result(
|
| 530 |
-
self,
|
| 531 |
-
prompt: str,
|
| 532 |
-
model: str,
|
| 533 |
-
index: int,
|
| 534 |
-
total: int,
|
| 535 |
-
request_token: str,
|
| 536 |
-
size: str | None = None,
|
| 537 |
-
response_format: str = "b64_json",
|
| 538 |
-
base_url: str | None = None,
|
| 539 |
-
images: list[str] | None = None,
|
| 540 |
-
) -> Iterator[dict[str, object]]:
|
| 541 |
-
stream = self._new_backend(request_token).stream_image_chat_completions(
|
| 542 |
-
prompt=prompt,
|
| 543 |
-
model=model,
|
| 544 |
-
size=size,
|
| 545 |
-
images=images or None,
|
| 546 |
-
)
|
| 547 |
-
for chunk in stream:
|
| 548 |
-
created = int(chunk.get("created") or time.time()) if isinstance(chunk, dict) else int(time.time())
|
| 549 |
-
choices = chunk.get("choices") if isinstance(chunk, dict) else None
|
| 550 |
-
first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 551 |
-
delta = first_choice.get("delta") if isinstance(first_choice.get("delta"), dict) else {}
|
| 552 |
-
content = str(delta.get("content") or "")
|
| 553 |
-
finish_reason = str(first_choice.get("finish_reason") or "")
|
| 554 |
-
|
| 555 |
-
if "upstream_event" in chunk:
|
| 556 |
-
upstream_event = chunk.get("upstream_event")
|
| 557 |
-
upstream_event_type = ""
|
| 558 |
-
if isinstance(upstream_event, dict):
|
| 559 |
-
upstream_event_type = str(upstream_event.get("type") or "")
|
| 560 |
-
yield self._progress_chunk(model, index, total, created, content, upstream_event_type)
|
| 561 |
-
continue
|
| 562 |
-
|
| 563 |
-
formatted_result = self._stream_result_from_markdown(content, prompt, response_format, base_url, created)
|
| 564 |
-
if formatted_result:
|
| 565 |
-
yield {
|
| 566 |
-
"object": "image.generation.result",
|
| 567 |
-
"created": formatted_result.get("created"),
|
| 568 |
-
"model": model,
|
| 569 |
-
"index": index,
|
| 570 |
-
"total": total,
|
| 571 |
-
"data": formatted_result.get("data") if isinstance(formatted_result.get("data"), list) else [],
|
| 572 |
-
}
|
| 573 |
-
continue
|
| 574 |
-
|
| 575 |
-
if finish_reason:
|
| 576 |
-
yield {
|
| 577 |
-
"object": "image.generation.done",
|
| 578 |
-
"created": created,
|
| 579 |
-
"model": model,
|
| 580 |
-
"index": index,
|
| 581 |
-
"total": total,
|
| 582 |
-
"data": [],
|
| 583 |
-
"finish_reason": finish_reason,
|
| 584 |
-
}
|
| 585 |
-
|
| 586 |
-
def _iter_generated_images_with_pool(
|
| 587 |
-
self,
|
| 588 |
-
prompt: str,
|
| 589 |
-
model: str,
|
| 590 |
-
n: int,
|
| 591 |
-
size: str | None = None,
|
| 592 |
-
response_format: str = "b64_json",
|
| 593 |
-
base_url: str | None = None,
|
| 594 |
-
) -> Iterator[dict[str, object]]:
|
| 595 |
-
emitted = False
|
| 596 |
-
last_error = ""
|
| 597 |
-
for index in range(1, n + 1):
|
| 598 |
-
while True:
|
| 599 |
-
try:
|
| 600 |
-
request_token = self.account_service.get_available_access_token()
|
| 601 |
-
except RuntimeError as exc:
|
| 602 |
-
last_error = str(exc)
|
| 603 |
-
logger.warning({
|
| 604 |
-
"event": "image_generate_stop",
|
| 605 |
-
"index": index,
|
| 606 |
-
"total": n,
|
| 607 |
-
"error": last_error,
|
| 608 |
-
})
|
| 609 |
-
if emitted:
|
| 610 |
-
return
|
| 611 |
-
raise ImageGenerationError(last_error or "image generation failed") from exc
|
| 612 |
-
|
| 613 |
-
logger.info({
|
| 614 |
-
"event": "image_generate_start",
|
| 615 |
-
"request_token": request_token,
|
| 616 |
-
"model": model,
|
| 617 |
-
"index": index,
|
| 618 |
-
"total": n,
|
| 619 |
-
})
|
| 620 |
-
try:
|
| 621 |
-
result = self._format_image_result(self._new_backend(request_token).images_generations(
|
| 622 |
-
prompt=prompt,
|
| 623 |
-
model=model,
|
| 624 |
-
size=size,
|
| 625 |
-
response_format="b64_json",
|
| 626 |
-
), prompt, response_format, base_url)
|
| 627 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 628 |
-
data = result.get("data")
|
| 629 |
-
image_items = [item for item in data if isinstance(item, dict)] if isinstance(data, list) else []
|
| 630 |
-
logger.info({
|
| 631 |
-
"event": "image_generate_success",
|
| 632 |
-
"request_token": request_token,
|
| 633 |
-
"quota": account.get("quota") if account else "unknown",
|
| 634 |
-
"status": account.get("status") if account else "unknown",
|
| 635 |
-
})
|
| 636 |
-
if image_items:
|
| 637 |
-
emitted = True
|
| 638 |
-
yield {
|
| 639 |
-
"created": result.get("created"),
|
| 640 |
-
"data": image_items,
|
| 641 |
-
}
|
| 642 |
-
break
|
| 643 |
-
except Exception as exc:
|
| 644 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 645 |
-
message = str(exc)
|
| 646 |
-
last_error = message
|
| 647 |
-
logger.warning({
|
| 648 |
-
"event": "image_generate_fail",
|
| 649 |
-
"request_token": request_token,
|
| 650 |
-
"error": message,
|
| 651 |
-
"quota": account.get("quota") if account else "unknown",
|
| 652 |
-
"status": account.get("status") if account else "unknown",
|
| 653 |
-
})
|
| 654 |
-
if is_token_invalid_error(message):
|
| 655 |
-
self._remove_invalid_token(request_token, "image_generate")
|
| 656 |
-
logger.warning({
|
| 657 |
-
"event": "image_generate_remove_invalid_token",
|
| 658 |
-
"request_token": request_token,
|
| 659 |
-
})
|
| 660 |
-
continue
|
| 661 |
-
break
|
| 662 |
-
|
| 663 |
-
if not emitted:
|
| 664 |
-
raise ImageGenerationError(last_error or "image generation failed")
|
| 665 |
-
|
| 666 |
-
def generate_with_pool(self, prompt: str, model: str, n: int, size: str | None = None, response_format: str = "b64_json",
|
| 667 |
-
base_url: str = None):
|
| 668 |
-
created = None
|
| 669 |
-
image_items: list[dict[str, object]] = []
|
| 670 |
-
for result in self._iter_generated_images_with_pool(prompt, model, n, size, response_format, base_url):
|
| 671 |
-
if created is None:
|
| 672 |
-
created = result.get("created")
|
| 673 |
-
data = result.get("data")
|
| 674 |
-
if isinstance(data, list):
|
| 675 |
-
image_items.extend(item for item in data if isinstance(item, dict))
|
| 676 |
-
result = {
|
| 677 |
-
"created": created,
|
| 678 |
-
"data": image_items,
|
| 679 |
-
}
|
| 680 |
-
return result
|
| 681 |
-
|
| 682 |
-
def stream_image_generation(
|
| 683 |
-
self,
|
| 684 |
-
prompt: str,
|
| 685 |
-
model: str,
|
| 686 |
-
n: int,
|
| 687 |
-
size: str | None = None,
|
| 688 |
-
response_format: str = "b64_json",
|
| 689 |
-
base_url: str | None = None,
|
| 690 |
-
) -> Iterator[dict[str, object]]:
|
| 691 |
-
last_error = ""
|
| 692 |
-
emitted = False
|
| 693 |
-
for index in range(1, n + 1):
|
| 694 |
-
while True:
|
| 695 |
-
try:
|
| 696 |
-
request_token = self.account_service.get_available_access_token()
|
| 697 |
-
except RuntimeError as exc:
|
| 698 |
-
last_error = str(exc)
|
| 699 |
-
logger.warning({
|
| 700 |
-
"event": "image_generate_stream_stop",
|
| 701 |
-
"index": index,
|
| 702 |
-
"total": n,
|
| 703 |
-
"error": last_error,
|
| 704 |
-
})
|
| 705 |
-
if emitted:
|
| 706 |
-
return
|
| 707 |
-
raise ImageGenerationError(last_error or "image generation failed") from exc
|
| 708 |
-
|
| 709 |
-
logger.info({
|
| 710 |
-
"event": "image_generate_stream_start",
|
| 711 |
-
"request_token": request_token,
|
| 712 |
-
"model": model,
|
| 713 |
-
"index": index,
|
| 714 |
-
"total": n,
|
| 715 |
-
})
|
| 716 |
-
emitted_for_request = False
|
| 717 |
-
has_result = False
|
| 718 |
-
try:
|
| 719 |
-
for chunk in self._stream_single_image_result(
|
| 720 |
-
prompt,
|
| 721 |
-
model,
|
| 722 |
-
index,
|
| 723 |
-
n,
|
| 724 |
-
request_token,
|
| 725 |
-
size,
|
| 726 |
-
response_format,
|
| 727 |
-
base_url,
|
| 728 |
-
):
|
| 729 |
-
emitted = True
|
| 730 |
-
emitted_for_request = True
|
| 731 |
-
data = chunk.get("data")
|
| 732 |
-
if isinstance(data, list) and data:
|
| 733 |
-
has_result = True
|
| 734 |
-
yield chunk
|
| 735 |
-
if not has_result:
|
| 736 |
-
last_error = "image generation failed"
|
| 737 |
-
raise ImageGenerationError(last_error)
|
| 738 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 739 |
-
logger.info({
|
| 740 |
-
"event": "image_generate_stream_success",
|
| 741 |
-
"request_token": request_token,
|
| 742 |
-
"quota": account.get("quota") if account else "unknown",
|
| 743 |
-
"status": account.get("status") if account else "unknown",
|
| 744 |
-
"has_result": has_result,
|
| 745 |
-
})
|
| 746 |
-
break
|
| 747 |
-
except Exception as exc:
|
| 748 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 749 |
-
message = str(exc)
|
| 750 |
-
last_error = message
|
| 751 |
-
logger.warning({
|
| 752 |
-
"event": "image_generate_stream_fail",
|
| 753 |
-
"request_token": request_token,
|
| 754 |
-
"error": message,
|
| 755 |
-
"quota": account.get("quota") if account else "unknown",
|
| 756 |
-
"status": account.get("status") if account else "unknown",
|
| 757 |
-
})
|
| 758 |
-
if not emitted_for_request and is_token_invalid_error(message):
|
| 759 |
-
self._remove_invalid_token(request_token, "image_generate_stream")
|
| 760 |
-
logger.warning({
|
| 761 |
-
"event": "image_generate_stream_remove_invalid_token",
|
| 762 |
-
"request_token": request_token,
|
| 763 |
-
})
|
| 764 |
-
continue
|
| 765 |
-
raise ImageGenerationError(last_error or "image generation failed") from exc
|
| 766 |
-
|
| 767 |
-
def edit_with_pool(
|
| 768 |
-
self,
|
| 769 |
-
prompt: str,
|
| 770 |
-
images: Iterable[tuple[bytes, str, str]],
|
| 771 |
-
model: str,
|
| 772 |
-
n: int,
|
| 773 |
-
size: str | None = None,
|
| 774 |
-
response_format: str = "b64_json",
|
| 775 |
-
base_url: str = None,
|
| 776 |
-
):
|
| 777 |
-
created = None
|
| 778 |
-
image_items: list[dict[str, object]] = []
|
| 779 |
-
last_error = ""
|
| 780 |
-
normalized_images = list(images)
|
| 781 |
-
if not normalized_images:
|
| 782 |
-
raise ImageGenerationError("image is required")
|
| 783 |
-
|
| 784 |
-
for index in range(1, n + 1):
|
| 785 |
-
while True:
|
| 786 |
-
try:
|
| 787 |
-
request_token = self.account_service.get_available_access_token()
|
| 788 |
-
except RuntimeError as exc:
|
| 789 |
-
last_error = str(exc)
|
| 790 |
-
logger.warning({
|
| 791 |
-
"event": "image_edit_stop",
|
| 792 |
-
"index": index,
|
| 793 |
-
"total": n,
|
| 794 |
-
"error": last_error,
|
| 795 |
-
})
|
| 796 |
-
break
|
| 797 |
-
|
| 798 |
-
logger.info({
|
| 799 |
-
"event": "image_edit_start",
|
| 800 |
-
"request_token": request_token,
|
| 801 |
-
"model": model,
|
| 802 |
-
"index": index,
|
| 803 |
-
"total": n,
|
| 804 |
-
"image_count": len(normalized_images),
|
| 805 |
-
})
|
| 806 |
-
try:
|
| 807 |
-
result = self._format_image_result(self._new_backend(request_token).images_edits(
|
| 808 |
-
image=self._encode_images(normalized_images),
|
| 809 |
-
prompt=prompt,
|
| 810 |
-
model=model,
|
| 811 |
-
size=size,
|
| 812 |
-
response_format="b64_json",
|
| 813 |
-
), prompt, response_format, base_url)
|
| 814 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 815 |
-
if created is None:
|
| 816 |
-
created = result.get("created")
|
| 817 |
-
data = result.get("data")
|
| 818 |
-
if isinstance(data, list):
|
| 819 |
-
image_items.extend(item for item in data if isinstance(item, dict))
|
| 820 |
-
logger.info({
|
| 821 |
-
"event": "image_edit_success",
|
| 822 |
-
"request_token": request_token,
|
| 823 |
-
"quota": account.get("quota") if account else "unknown",
|
| 824 |
-
"status": account.get("status") if account else "unknown",
|
| 825 |
-
})
|
| 826 |
-
break
|
| 827 |
-
except Exception as exc:
|
| 828 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 829 |
-
message = str(exc)
|
| 830 |
-
last_error = message
|
| 831 |
-
logger.warning({
|
| 832 |
-
"event": "image_edit_fail",
|
| 833 |
-
"request_token": request_token,
|
| 834 |
-
"error": message,
|
| 835 |
-
"quota": account.get("quota") if account else "unknown",
|
| 836 |
-
"status": account.get("status") if account else "unknown",
|
| 837 |
-
})
|
| 838 |
-
if is_token_invalid_error(message):
|
| 839 |
-
self._remove_invalid_token(request_token, "image_edit")
|
| 840 |
-
logger.warning({
|
| 841 |
-
"event": "image_edit_remove_invalid_token",
|
| 842 |
-
"request_token": request_token,
|
| 843 |
-
})
|
| 844 |
-
continue
|
| 845 |
-
break
|
| 846 |
-
|
| 847 |
-
if not image_items:
|
| 848 |
-
raise ImageGenerationError(last_error or "image edit failed")
|
| 849 |
-
|
| 850 |
-
result = {
|
| 851 |
-
"created": created,
|
| 852 |
-
"data": image_items,
|
| 853 |
-
}
|
| 854 |
-
return result
|
| 855 |
-
|
| 856 |
-
def stream_image_edit(
|
| 857 |
-
self,
|
| 858 |
-
prompt: str,
|
| 859 |
-
images: Iterable[tuple[bytes, str, str]],
|
| 860 |
-
model: str,
|
| 861 |
-
n: int,
|
| 862 |
-
size: str | None = None,
|
| 863 |
-
response_format: str = "b64_json",
|
| 864 |
-
base_url: str | None = None,
|
| 865 |
-
) -> Iterator[dict[str, object]]:
|
| 866 |
-
last_error = ""
|
| 867 |
-
emitted = False
|
| 868 |
-
normalized_images = list(images)
|
| 869 |
-
if not normalized_images:
|
| 870 |
-
raise ImageGenerationError("image is required")
|
| 871 |
-
encoded_images = self._encode_images(normalized_images)
|
| 872 |
-
|
| 873 |
-
for index in range(1, n + 1):
|
| 874 |
-
while True:
|
| 875 |
-
try:
|
| 876 |
-
request_token = self.account_service.get_available_access_token()
|
| 877 |
-
except RuntimeError as exc:
|
| 878 |
-
last_error = str(exc)
|
| 879 |
-
logger.warning({
|
| 880 |
-
"event": "image_edit_stream_stop",
|
| 881 |
-
"index": index,
|
| 882 |
-
"total": n,
|
| 883 |
-
"error": last_error,
|
| 884 |
-
})
|
| 885 |
-
if emitted:
|
| 886 |
-
return
|
| 887 |
-
raise ImageGenerationError(last_error or "image edit failed") from exc
|
| 888 |
-
|
| 889 |
-
logger.info({
|
| 890 |
-
"event": "image_edit_stream_start",
|
| 891 |
-
"request_token": request_token,
|
| 892 |
-
"model": model,
|
| 893 |
-
"index": index,
|
| 894 |
-
"total": n,
|
| 895 |
-
"image_count": len(normalized_images),
|
| 896 |
-
})
|
| 897 |
-
emitted_for_request = False
|
| 898 |
-
has_result = False
|
| 899 |
-
try:
|
| 900 |
-
for chunk in self._stream_single_image_result(
|
| 901 |
-
prompt=prompt,
|
| 902 |
-
model=model,
|
| 903 |
-
index=index,
|
| 904 |
-
total=n,
|
| 905 |
-
request_token=request_token,
|
| 906 |
-
size=size,
|
| 907 |
-
response_format=response_format,
|
| 908 |
-
base_url=base_url,
|
| 909 |
-
images=encoded_images,
|
| 910 |
-
):
|
| 911 |
-
emitted = True
|
| 912 |
-
emitted_for_request = True
|
| 913 |
-
data = chunk.get("data")
|
| 914 |
-
if isinstance(data, list) and data:
|
| 915 |
-
has_result = True
|
| 916 |
-
yield chunk
|
| 917 |
-
if not has_result:
|
| 918 |
-
last_error = "image edit failed"
|
| 919 |
-
raise ImageGenerationError(last_error)
|
| 920 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 921 |
-
logger.info({
|
| 922 |
-
"event": "image_edit_stream_success",
|
| 923 |
-
"request_token": request_token,
|
| 924 |
-
"quota": account.get("quota") if account else "unknown",
|
| 925 |
-
"status": account.get("status") if account else "unknown",
|
| 926 |
-
"has_result": has_result,
|
| 927 |
-
})
|
| 928 |
-
break
|
| 929 |
-
except Exception as exc:
|
| 930 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 931 |
-
message = str(exc)
|
| 932 |
-
last_error = message
|
| 933 |
-
logger.warning({
|
| 934 |
-
"event": "image_edit_stream_fail",
|
| 935 |
-
"request_token": request_token,
|
| 936 |
-
"error": message,
|
| 937 |
-
"quota": account.get("quota") if account else "unknown",
|
| 938 |
-
"status": account.get("status") if account else "unknown",
|
| 939 |
-
})
|
| 940 |
-
if not emitted_for_request and is_token_invalid_error(message):
|
| 941 |
-
self._remove_invalid_token(request_token, "image_edit_stream")
|
| 942 |
-
logger.warning({
|
| 943 |
-
"event": "image_edit_stream_remove_invalid_token",
|
| 944 |
-
"request_token": request_token,
|
| 945 |
-
})
|
| 946 |
-
continue
|
| 947 |
-
raise ImageGenerationError(last_error or "image edit failed") from exc
|
| 948 |
-
|
| 949 |
-
@staticmethod
|
| 950 |
-
def _stream_completion_response(result: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 951 |
-
completion_id = str(result.get("id") or f"chatcmpl-{uuid.uuid4().hex}")
|
| 952 |
-
created = int(result.get("created") or time.time())
|
| 953 |
-
model = str(result.get("model") or "auto")
|
| 954 |
-
choices = result.get("choices")
|
| 955 |
-
first_choice = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 956 |
-
message = first_choice.get("message") if isinstance(first_choice.get("message"), dict) else {}
|
| 957 |
-
content = str(message.get("content") or "")
|
| 958 |
-
finish_reason = str(first_choice.get("finish_reason") or "stop")
|
| 959 |
-
|
| 960 |
-
yield {
|
| 961 |
-
"id": completion_id,
|
| 962 |
-
"object": "chat.completion.chunk",
|
| 963 |
-
"created": created,
|
| 964 |
-
"model": model,
|
| 965 |
-
"choices": [{
|
| 966 |
-
"index": 0,
|
| 967 |
-
"delta": {"role": "assistant", "content": content},
|
| 968 |
-
"finish_reason": None,
|
| 969 |
-
}],
|
| 970 |
-
}
|
| 971 |
-
yield {
|
| 972 |
-
"id": completion_id,
|
| 973 |
-
"object": "chat.completion.chunk",
|
| 974 |
-
"created": created,
|
| 975 |
-
"model": model,
|
| 976 |
-
"choices": [{
|
| 977 |
-
"index": 0,
|
| 978 |
-
"delta": {},
|
| 979 |
-
"finish_reason": finish_reason,
|
| 980 |
-
}],
|
| 981 |
-
}
|
| 982 |
-
|
| 983 |
-
def _create_image_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 984 |
-
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 985 |
-
n = parse_image_count(body.get("n"))
|
| 986 |
-
prompt = extract_chat_prompt(body)
|
| 987 |
-
if not prompt:
|
| 988 |
-
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 989 |
-
|
| 990 |
-
image_infos = extract_chat_image(body)
|
| 991 |
-
try:
|
| 992 |
-
if image_infos:
|
| 993 |
-
images = [(data, f"image_{idx}.png", mime) for idx, (data, mime) in enumerate(image_infos, start=1)]
|
| 994 |
-
image_result = self.edit_with_pool(prompt, images, model, n)
|
| 995 |
-
else:
|
| 996 |
-
image_result = self.generate_with_pool(prompt, model, n)
|
| 997 |
-
except ImageGenerationError as exc:
|
| 998 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 999 |
-
|
| 1000 |
-
return build_chat_image_completion(model, image_result)
|
| 1001 |
-
|
| 1002 |
-
def _stream_image_chat_completion(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 1003 |
-
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 1004 |
-
n = parse_image_count(body.get("n"))
|
| 1005 |
-
if n != 1:
|
| 1006 |
-
result = self._create_image_chat_completion(body)
|
| 1007 |
-
yield from self._stream_completion_response(result)
|
| 1008 |
-
return
|
| 1009 |
-
|
| 1010 |
-
prompt = extract_chat_prompt(body)
|
| 1011 |
-
if not prompt:
|
| 1012 |
-
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 1013 |
-
|
| 1014 |
-
image_infos = extract_chat_image(body)
|
| 1015 |
-
encoded_images = []
|
| 1016 |
-
if image_infos:
|
| 1017 |
-
images = [(data, f"image_{idx}.png", mime) for idx, (data, mime) in enumerate(image_infos, start=1)]
|
| 1018 |
-
encoded_images = self._encode_images(images)
|
| 1019 |
-
|
| 1020 |
-
last_error = ""
|
| 1021 |
-
while True:
|
| 1022 |
-
try:
|
| 1023 |
-
request_token = self.account_service.get_available_access_token()
|
| 1024 |
-
except RuntimeError as exc:
|
| 1025 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1026 |
-
|
| 1027 |
-
logger.info({
|
| 1028 |
-
"event": "image_stream_start",
|
| 1029 |
-
"request_token": request_token,
|
| 1030 |
-
"model": model,
|
| 1031 |
-
})
|
| 1032 |
-
emitted = False
|
| 1033 |
-
try:
|
| 1034 |
-
stream = self._new_backend(request_token).stream_image_chat_completions(
|
| 1035 |
-
prompt=prompt,
|
| 1036 |
-
model=model,
|
| 1037 |
-
images=encoded_images or None,
|
| 1038 |
-
)
|
| 1039 |
-
for chunk in stream:
|
| 1040 |
-
emitted = True
|
| 1041 |
-
yield chunk
|
| 1042 |
-
account = self.account_service.mark_image_result(request_token, success=True)
|
| 1043 |
-
logger.info({
|
| 1044 |
-
"event": "image_stream_success",
|
| 1045 |
-
"request_token": request_token,
|
| 1046 |
-
"quota": account.get("quota") if account else "unknown",
|
| 1047 |
-
"status": account.get("status") if account else "unknown",
|
| 1048 |
-
})
|
| 1049 |
-
return
|
| 1050 |
-
except Exception as exc:
|
| 1051 |
-
account = self.account_service.mark_image_result(request_token, success=False)
|
| 1052 |
-
message = str(exc)
|
| 1053 |
-
last_error = message
|
| 1054 |
-
logger.warning({
|
| 1055 |
-
"event": "image_stream_fail",
|
| 1056 |
-
"request_token": request_token,
|
| 1057 |
-
"error": message,
|
| 1058 |
-
"quota": account.get("quota") if account else "unknown",
|
| 1059 |
-
"status": account.get("status") if account else "unknown",
|
| 1060 |
-
})
|
| 1061 |
-
if not emitted and is_token_invalid_error(message):
|
| 1062 |
-
self._remove_invalid_token(request_token, "image_stream")
|
| 1063 |
-
logger.warning({
|
| 1064 |
-
"event": "image_stream_remove_invalid_token",
|
| 1065 |
-
"request_token": request_token,
|
| 1066 |
-
})
|
| 1067 |
-
continue
|
| 1068 |
-
raise HTTPException(status_code=502, detail={"error": last_error or "image generation failed"}) from exc
|
| 1069 |
-
|
| 1070 |
-
def _create_text_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 1071 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 1072 |
-
messages = self._chat_messages_from_body(body)
|
| 1073 |
-
try:
|
| 1074 |
-
result = self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=False)
|
| 1075 |
-
return result
|
| 1076 |
-
except Exception as exc:
|
| 1077 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1078 |
-
|
| 1079 |
-
def create_chat_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 1080 |
-
if is_image_chat_request(body):
|
| 1081 |
-
return self._create_image_chat_completion(body)
|
| 1082 |
-
return self._create_text_chat_completion(body)
|
| 1083 |
-
|
| 1084 |
-
def stream_chat_completion(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 1085 |
-
if is_image_chat_request(body):
|
| 1086 |
-
yield from self._stream_image_chat_completion(body)
|
| 1087 |
-
return
|
| 1088 |
-
|
| 1089 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 1090 |
-
messages = self._chat_messages_from_body(body)
|
| 1091 |
-
try:
|
| 1092 |
-
yield from self._new_backend(self._get_text_access_token()).chat_completions(messages=messages, model=model, stream=True)
|
| 1093 |
-
except Exception as exc:
|
| 1094 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1095 |
-
|
| 1096 |
-
def create_message(self, body: dict[str, object]) -> dict[str, object]:
|
| 1097 |
-
body = preprocess_payload(dict(body))
|
| 1098 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 1099 |
-
messages = self._chat_messages_from_body(body)
|
| 1100 |
-
try:
|
| 1101 |
-
result = self._new_backend(self._get_text_access_token()).messages(
|
| 1102 |
-
messages=messages,
|
| 1103 |
-
model=model,
|
| 1104 |
-
stream=False,
|
| 1105 |
-
system=body.get("system"),
|
| 1106 |
-
tools=body.get("tools"),
|
| 1107 |
-
)
|
| 1108 |
-
return result
|
| 1109 |
-
except Exception as exc:
|
| 1110 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1111 |
-
|
| 1112 |
-
def stream_message(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 1113 |
-
body = preprocess_payload(dict(body))
|
| 1114 |
-
model = str(body.get("model") or "auto").strip() or "auto"
|
| 1115 |
-
messages = self._chat_messages_from_body(body)
|
| 1116 |
-
try:
|
| 1117 |
-
yield from self._new_backend(self._get_text_access_token()).messages(
|
| 1118 |
-
messages=messages,
|
| 1119 |
-
model=model,
|
| 1120 |
-
stream=True,
|
| 1121 |
-
system=body.get("system"),
|
| 1122 |
-
tools=body.get("tools"),
|
| 1123 |
-
)
|
| 1124 |
-
except Exception as exc:
|
| 1125 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1126 |
-
|
| 1127 |
-
def create_image_completion(self, body: dict[str, object]) -> dict[str, object]:
|
| 1128 |
-
if not is_image_chat_request(body):
|
| 1129 |
-
raise HTTPException(
|
| 1130 |
-
status_code=400,
|
| 1131 |
-
detail={"error": "only image generation requests are supported on this endpoint"},
|
| 1132 |
-
)
|
| 1133 |
-
return self._create_image_chat_completion(body)
|
| 1134 |
-
|
| 1135 |
-
def _get_response_access_token(self, body: dict[str, object]) -> str:
|
| 1136 |
-
return self.account_service.get_available_access_token()
|
| 1137 |
-
|
| 1138 |
-
def stream_response(self, body: dict[str, object]) -> Iterator[dict[str, object]]:
|
| 1139 |
-
if self._is_text_response_request(body):
|
| 1140 |
-
yield from self._stream_text_response(body)
|
| 1141 |
-
return
|
| 1142 |
-
if not self._is_codex_image_response_request(body):
|
| 1143 |
-
yield from self._stream_token_image_response(body)
|
| 1144 |
-
return
|
| 1145 |
-
try:
|
| 1146 |
-
access_token = self._get_response_access_token(body)
|
| 1147 |
-
yield from self._new_backend(access_token).responses(
|
| 1148 |
-
input=body.get("input") or "",
|
| 1149 |
-
model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
|
| 1150 |
-
tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
|
| 1151 |
-
instructions=str(body.get("instructions") or "you are a helpful assistant"),
|
| 1152 |
-
tool_choice=body.get("tool_choice") or "auto",
|
| 1153 |
-
stream=True,
|
| 1154 |
-
store=bool(body.get("store")),
|
| 1155 |
-
)
|
| 1156 |
-
except Exception as exc:
|
| 1157 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
| 1158 |
-
|
| 1159 |
-
def create_response(self, body: dict[str, object]) -> dict[str, object]:
|
| 1160 |
-
if self._is_text_response_request(body):
|
| 1161 |
-
result = self._create_text_response(body)
|
| 1162 |
-
return result
|
| 1163 |
-
if not self._is_codex_image_response_request(body):
|
| 1164 |
-
return self._create_token_image_response(body)
|
| 1165 |
-
try:
|
| 1166 |
-
access_token = self._get_response_access_token(body)
|
| 1167 |
-
result = self._new_backend(access_token).responses(
|
| 1168 |
-
input=body.get("input") or "",
|
| 1169 |
-
model=str(body.get("model") or "gpt-5.4").strip() or "gpt-5.4",
|
| 1170 |
-
tools=body.get("tools") if isinstance(body.get("tools"), list) else None,
|
| 1171 |
-
instructions=str(body.get("instructions") or "you are a helpful assistant"),
|
| 1172 |
-
tool_choice=body.get("tool_choice") or "auto",
|
| 1173 |
-
stream=False,
|
| 1174 |
-
store=bool(body.get("store")),
|
| 1175 |
-
)
|
| 1176 |
-
return result
|
| 1177 |
-
except Exception as exc:
|
| 1178 |
-
raise HTTPException(status_code=502, detail={"error": str(exc)}) from exc
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/config.py
CHANGED
|
@@ -117,6 +117,14 @@ class ConfigStore:
|
|
| 117 |
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 118 |
return bool(value)
|
| 119 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 120 |
@property
|
| 121 |
def images_dir(self) -> Path:
|
| 122 |
path = DATA_DIR / "images"
|
|
@@ -158,6 +166,7 @@ class ConfigStore:
|
|
| 158 |
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 159 |
data["image_retention_days"] = self.image_retention_days
|
| 160 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
|
|
|
| 161 |
data.pop("auth-key", None)
|
| 162 |
return data
|
| 163 |
|
|
|
|
| 117 |
return value.strip().lower() in {"1", "true", "yes", "on"}
|
| 118 |
return bool(value)
|
| 119 |
|
| 120 |
+
@property
|
| 121 |
+
def log_levels(self) -> list[str]:
|
| 122 |
+
levels = self.data.get("log_levels")
|
| 123 |
+
if not isinstance(levels, list):
|
| 124 |
+
return []
|
| 125 |
+
allowed = {"debug", "info", "warning", "error"}
|
| 126 |
+
return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
|
| 127 |
+
|
| 128 |
@property
|
| 129 |
def images_dir(self) -> Path:
|
| 130 |
path = DATA_DIR / "images"
|
|
|
|
| 166 |
data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
|
| 167 |
data["image_retention_days"] = self.image_retention_days
|
| 168 |
data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
|
| 169 |
+
data["log_levels"] = self.log_levels
|
| 170 |
data.pop("auth-key", None)
|
| 171 |
return data
|
| 172 |
|
services/log_service.py
CHANGED
|
@@ -1,15 +1,23 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
|
|
|
|
|
|
|
|
|
| 4 |
from datetime import datetime
|
| 5 |
from pathlib import Path
|
| 6 |
from typing import Any
|
| 7 |
|
|
|
|
|
|
|
|
|
|
| 8 |
from services.config import DATA_DIR
|
|
|
|
| 9 |
|
| 10 |
LOG_TYPE_CALL = "call"
|
| 11 |
LOG_TYPE_ACCOUNT = "account"
|
| 12 |
|
|
|
|
| 13 |
class LogService:
|
| 14 |
def __init__(self, path: Path):
|
| 15 |
self.path = path
|
|
@@ -49,3 +57,119 @@ class LogService:
|
|
| 49 |
|
| 50 |
|
| 51 |
log_service = LogService(DATA_DIR / "logs.jsonl")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
import json
|
| 4 |
+
import itertools
|
| 5 |
+
import time
|
| 6 |
+
from dataclasses import dataclass, field
|
| 7 |
from datetime import datetime
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any
|
| 10 |
|
| 11 |
+
from fastapi.concurrency import run_in_threadpool
|
| 12 |
+
from fastapi.responses import JSONResponse, StreamingResponse
|
| 13 |
+
|
| 14 |
from services.config import DATA_DIR
|
| 15 |
+
from utils.helper import anthropic_sse_stream, sse_json_stream
|
| 16 |
|
| 17 |
LOG_TYPE_CALL = "call"
|
| 18 |
LOG_TYPE_ACCOUNT = "account"
|
| 19 |
|
| 20 |
+
|
| 21 |
class LogService:
|
| 22 |
def __init__(self, path: Path):
|
| 23 |
self.path = path
|
|
|
|
| 57 |
|
| 58 |
|
| 59 |
log_service = LogService(DATA_DIR / "logs.jsonl")
|
| 60 |
+
|
| 61 |
+
|
| 62 |
+
def _collect_urls(value: object) -> list[str]:
|
| 63 |
+
urls: list[str] = []
|
| 64 |
+
if isinstance(value, dict):
|
| 65 |
+
for key, item in value.items():
|
| 66 |
+
if key == "url" and isinstance(item, str):
|
| 67 |
+
urls.append(item)
|
| 68 |
+
elif key == "urls" and isinstance(item, list):
|
| 69 |
+
urls.extend(str(url) for url in item if isinstance(url, str))
|
| 70 |
+
else:
|
| 71 |
+
urls.extend(_collect_urls(item))
|
| 72 |
+
elif isinstance(value, list):
|
| 73 |
+
for item in value:
|
| 74 |
+
urls.extend(_collect_urls(item))
|
| 75 |
+
return urls
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
def _image_error_response(exc: Exception) -> JSONResponse:
|
| 79 |
+
message = str(exc)
|
| 80 |
+
if "no available image quota" in message.lower():
|
| 81 |
+
return JSONResponse(
|
| 82 |
+
status_code=429,
|
| 83 |
+
content={
|
| 84 |
+
"error": {
|
| 85 |
+
"message": "no available image quota",
|
| 86 |
+
"type": "insufficient_quota",
|
| 87 |
+
"param": None,
|
| 88 |
+
"code": "insufficient_quota",
|
| 89 |
+
}
|
| 90 |
+
},
|
| 91 |
+
)
|
| 92 |
+
if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
|
| 93 |
+
return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
|
| 94 |
+
return JSONResponse(
|
| 95 |
+
status_code=502,
|
| 96 |
+
content={
|
| 97 |
+
"error": {
|
| 98 |
+
"message": message,
|
| 99 |
+
"type": "server_error",
|
| 100 |
+
"param": None,
|
| 101 |
+
"code": "upstream_error",
|
| 102 |
+
}
|
| 103 |
+
},
|
| 104 |
+
)
|
| 105 |
+
|
| 106 |
+
|
| 107 |
+
@dataclass
|
| 108 |
+
class LoggedCall:
|
| 109 |
+
identity: dict[str, object]
|
| 110 |
+
endpoint: str
|
| 111 |
+
model: str
|
| 112 |
+
summary: str
|
| 113 |
+
started: float = field(default_factory=time.time)
|
| 114 |
+
|
| 115 |
+
async def run(self, handler, *args, sse: str = "openai"):
|
| 116 |
+
from services.protocol.conversation import ImageGenerationError
|
| 117 |
+
|
| 118 |
+
try:
|
| 119 |
+
result = await run_in_threadpool(handler, *args)
|
| 120 |
+
except ImageGenerationError as exc:
|
| 121 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 122 |
+
return _image_error_response(exc)
|
| 123 |
+
except Exception as exc:
|
| 124 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 125 |
+
raise
|
| 126 |
+
|
| 127 |
+
if isinstance(result, dict):
|
| 128 |
+
self.log("调用完成", result)
|
| 129 |
+
return result
|
| 130 |
+
|
| 131 |
+
sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
|
| 132 |
+
try:
|
| 133 |
+
first = next(result)
|
| 134 |
+
except StopIteration:
|
| 135 |
+
self.log("流式调用结束")
|
| 136 |
+
return StreamingResponse(sender(()), media_type="text/event-stream")
|
| 137 |
+
except ImageGenerationError as exc:
|
| 138 |
+
self.log("调用失败", status="failed", error=str(exc))
|
| 139 |
+
return _image_error_response(exc)
|
| 140 |
+
return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
|
| 141 |
+
|
| 142 |
+
def stream(self, items):
|
| 143 |
+
urls: list[str] = []
|
| 144 |
+
failed = False
|
| 145 |
+
try:
|
| 146 |
+
for item in items:
|
| 147 |
+
urls.extend(_collect_urls(item))
|
| 148 |
+
yield item
|
| 149 |
+
except Exception as exc:
|
| 150 |
+
failed = True
|
| 151 |
+
self.log("流式调用失败", status="failed", error=str(exc), urls=urls)
|
| 152 |
+
raise
|
| 153 |
+
finally:
|
| 154 |
+
if not failed:
|
| 155 |
+
self.log("流式调用结束", urls=urls)
|
| 156 |
+
|
| 157 |
+
def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
|
| 158 |
+
urls: list[str] | None = None) -> None:
|
| 159 |
+
detail = {
|
| 160 |
+
"key_id": self.identity.get("id"),
|
| 161 |
+
"key_name": self.identity.get("name"),
|
| 162 |
+
"role": self.identity.get("role"),
|
| 163 |
+
"endpoint": self.endpoint,
|
| 164 |
+
"model": self.model,
|
| 165 |
+
"started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
| 166 |
+
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
| 167 |
+
"duration_ms": int((time.time() - self.started) * 1000),
|
| 168 |
+
"status": status,
|
| 169 |
+
}
|
| 170 |
+
if error:
|
| 171 |
+
detail["error"] = error
|
| 172 |
+
collected_urls = [*(urls or []), *_collect_urls(result)]
|
| 173 |
+
if collected_urls:
|
| 174 |
+
detail["urls"] = list(dict.fromkeys(collected_urls))
|
| 175 |
+
log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
|
services/openai_backend_api.py
CHANGED
|
@@ -1,5 +1,4 @@
|
|
| 1 |
import base64
|
| 2 |
-
import json
|
| 3 |
import os
|
| 4 |
import re
|
| 5 |
import time
|
|
@@ -8,15 +7,12 @@ from io import BytesIO
|
|
| 8 |
from pathlib import Path
|
| 9 |
from typing import Any, Dict, Iterator, Optional
|
| 10 |
|
| 11 |
-
import tiktoken
|
| 12 |
from curl_cffi import requests
|
| 13 |
from PIL import Image
|
| 14 |
|
| 15 |
from services.account_service import account_service
|
| 16 |
-
from services.anthropic_protocol import message_response, stream_events
|
| 17 |
-
from services.config import config
|
| 18 |
from services.proxy_service import proxy_settings
|
| 19 |
-
from utils.helper import
|
| 20 |
from utils.log import logger
|
| 21 |
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 22 |
from utils.turnstile import solve_turnstile_token
|
|
@@ -36,7 +32,6 @@ DEFAULT_CLIENT_VERSION = "prod-be885abbfcfe7b1f511e88b3003d9ee44757fbad"
|
|
| 36 |
DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
|
| 37 |
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
| 38 |
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
| 39 |
-
CODEX_RESPONSE_MODEL = "gpt-5.4"
|
| 40 |
|
| 41 |
|
| 42 |
class OpenAIBackendAPI:
|
|
@@ -47,8 +42,8 @@ class OpenAIBackendAPI:
|
|
| 47 |
例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
|
| 48 |
- 不传 `access_token` 时,会走未登录链路
|
| 49 |
例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
|
| 50 |
-
-
|
| 51 |
-
-
|
| 52 |
"""
|
| 53 |
|
| 54 |
def __init__(self, access_token: str = "") -> None:
|
|
@@ -155,31 +150,6 @@ class OpenAIBackendAPI:
|
|
| 155 |
"Upgrade-Insecure-Requests": "1",
|
| 156 |
}
|
| 157 |
|
| 158 |
-
def _build_requirements_token(self) -> str:
|
| 159 |
-
"""生成 sentinel 接口需要的旧版 requirements token。"""
|
| 160 |
-
return build_legacy_requirements_token(
|
| 161 |
-
self.user_agent,
|
| 162 |
-
script_sources=self.pow_script_sources,
|
| 163 |
-
data_build=self.pow_data_build,
|
| 164 |
-
)
|
| 165 |
-
|
| 166 |
-
def _get_token_info(self) -> Dict[str, Any]:
|
| 167 |
-
"""从 access token 的 JWT payload 中提取后续请求可能会用到的信息。"""
|
| 168 |
-
if not self.access_token:
|
| 169 |
-
return {}
|
| 170 |
-
parts = self.access_token.split(".")
|
| 171 |
-
if len(parts) < 2:
|
| 172 |
-
return {}
|
| 173 |
-
payload = parts[1]
|
| 174 |
-
padding = "=" * (-len(payload) % 4)
|
| 175 |
-
try:
|
| 176 |
-
decoded = base64.urlsafe_b64decode(payload + padding).decode("utf-8")
|
| 177 |
-
data = json.loads(decoded)
|
| 178 |
-
except Exception:
|
| 179 |
-
return {}
|
| 180 |
-
auth_data = data.get("https://api.openai.com/auth") or {}
|
| 181 |
-
return auth_data
|
| 182 |
-
|
| 183 |
def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
|
| 184 |
"""把 sentinel 响应整理成后续对话需要的 token 集合。"""
|
| 185 |
if (data.get("arkose") or {}).get("required"):
|
|
@@ -271,58 +241,17 @@ class OpenAIBackendAPI:
|
|
| 271 |
},
|
| 272 |
}
|
| 273 |
|
| 274 |
-
def _normalize_models(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 275 |
-
"""把 web models 响应整理成 OpenAI `/v1/models` 风格结构。"""
|
| 276 |
-
data = []
|
| 277 |
-
seen = set()
|
| 278 |
-
for item in payload.get("models", []):
|
| 279 |
-
if not isinstance(item, dict):
|
| 280 |
-
continue
|
| 281 |
-
slug = str(item.get("slug", "")).strip()
|
| 282 |
-
if not slug or slug in seen:
|
| 283 |
-
continue
|
| 284 |
-
seen.add(slug)
|
| 285 |
-
data.append({
|
| 286 |
-
"id": slug,
|
| 287 |
-
"object": "model",
|
| 288 |
-
"created": int(item.get("created") or 0),
|
| 289 |
-
"owned_by": str(item.get("owned_by") or "chatgpt"),
|
| 290 |
-
"permission": [],
|
| 291 |
-
"root": slug,
|
| 292 |
-
"parent": None,
|
| 293 |
-
})
|
| 294 |
-
data.sort(key=lambda item: item["id"])
|
| 295 |
-
return {"object": "list", "data": data}
|
| 296 |
-
|
| 297 |
-
def _build_image_prompt(self, prompt: str, size: str | None) -> str:
|
| 298 |
-
"""把标准图片 prompt 和宽高比转成底层图片生成 prompt。"""
|
| 299 |
-
if not size:
|
| 300 |
-
return prompt
|
| 301 |
-
if size not in {"1:1", "16:9", "9:16", "4:3", "3:4"}:
|
| 302 |
-
return f"{prompt.strip()}\n\n输出图片,宽高比为 {size}。"
|
| 303 |
-
hint = {
|
| 304 |
-
"1:1": "输出为 1:1 正方形构图,主体居中,适合正方形画幅。",
|
| 305 |
-
"16:9": "输出为 16:9 横屏构图,适合宽画幅展示。",
|
| 306 |
-
"9:16": "输出为 9:16 竖屏构图,适合竖版画幅展示。",
|
| 307 |
-
"4:3": "输出为 4:3 比例,兼顾宽度与高度,适合展示画面细节。",
|
| 308 |
-
"3:4": "输出为 3:4 比例,纵向构图,适合人物肖像或竖向场景。",
|
| 309 |
-
}[size]
|
| 310 |
-
return f"{prompt.strip()}\n\n{hint}"
|
| 311 |
-
|
| 312 |
def _image_model_slug(self, model: str) -> str:
|
| 313 |
"""把标准图片模型名映射到底层 model slug。"""
|
| 314 |
model = str(model or "").strip()
|
| 315 |
if not model:
|
| 316 |
return "auto"
|
| 317 |
-
if model
|
| 318 |
return "gpt-5-3"
|
| 319 |
if model == CODEX_IMAGE_MODEL:
|
| 320 |
return model
|
| 321 |
return "auto"
|
| 322 |
|
| 323 |
-
def _is_codex_image_model(self, model: str) -> bool:
|
| 324 |
-
return model == CODEX_IMAGE_MODEL
|
| 325 |
-
|
| 326 |
def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
|
| 327 |
Dict[str, str]:
|
| 328 |
"""构造图片链路请求头。"""
|
|
@@ -385,16 +314,6 @@ class OpenAIBackendAPI:
|
|
| 385 |
payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
|
| 386 |
return base64.b64decode(payload)
|
| 387 |
|
| 388 |
-
def _image_to_data_url(self, image: str) -> str:
|
| 389 |
-
"""把本地图片路径或 base64 图片统一转成 data URL。"""
|
| 390 |
-
data = self._decode_image_base64(image)
|
| 391 |
-
try:
|
| 392 |
-
opened = Image.open(BytesIO(data))
|
| 393 |
-
mime_type = Image.MIME.get(opened.format, "image/png")
|
| 394 |
-
except Exception:
|
| 395 |
-
mime_type = "image/png"
|
| 396 |
-
return f"data:{mime_type};base64,{base64.b64encode(data).decode()}"
|
| 397 |
-
|
| 398 |
def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
|
| 399 |
"""上传一张 base64 图片,返回底层文件元数据。"""
|
| 400 |
data = self._decode_image_base64(image)
|
|
@@ -528,32 +447,6 @@ class OpenAIBackendAPI:
|
|
| 528 |
ensure_ok(response, path)
|
| 529 |
return response
|
| 530 |
|
| 531 |
-
def _parse_image_sse(self, response: requests.Response) -> Dict[str, Any]:
|
| 532 |
-
"""从图片 SSE 里提取 conversation_id、file_ids、sediment_ids。"""
|
| 533 |
-
conversation_id = ""
|
| 534 |
-
file_ids: list[str] = []
|
| 535 |
-
sediment_ids: list[str] = []
|
| 536 |
-
for raw_line in response.iter_lines():
|
| 537 |
-
if not raw_line:
|
| 538 |
-
continue
|
| 539 |
-
line = raw_line.decode("utf-8", errors="ignore")
|
| 540 |
-
if not line.startswith("data:"):
|
| 541 |
-
continue
|
| 542 |
-
payload = line[5:].strip()
|
| 543 |
-
if payload == "[DONE]":
|
| 544 |
-
break
|
| 545 |
-
if not conversation_id:
|
| 546 |
-
match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 547 |
-
if match:
|
| 548 |
-
conversation_id = match.group(1)
|
| 549 |
-
for file_id in re.findall(r"(file[-_][A-Za-z0-9]+)", payload):
|
| 550 |
-
if file_id not in file_ids:
|
| 551 |
-
file_ids.append(file_id)
|
| 552 |
-
for sediment_id in re.findall(r"sediment://([A-Za-z0-9_-]+)", payload):
|
| 553 |
-
if sediment_id not in sediment_ids:
|
| 554 |
-
sediment_ids.append(sediment_id)
|
| 555 |
-
return {"conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids}
|
| 556 |
-
|
| 557 |
def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
| 558 |
"""获取完整 conversation 详情。"""
|
| 559 |
path = f"/backend-api/conversation/{conversation_id}"
|
|
@@ -597,8 +490,10 @@ class OpenAIBackendAPI:
|
|
| 597 |
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 598 |
"""轮询 conversation,直到拿到图片文件 id 或超时。"""
|
| 599 |
start = time.time()
|
| 600 |
-
|
|
|
|
| 601 |
while time.time() - start < timeout_secs:
|
|
|
|
| 602 |
conversation = self._get_conversation(conversation_id)
|
| 603 |
file_ids, sediment_ids = [], []
|
| 604 |
for record in self._extract_image_tool_records(conversation):
|
|
@@ -608,12 +503,17 @@ class OpenAIBackendAPI:
|
|
| 608 |
for sediment_id in record["sediment_ids"]:
|
| 609 |
if sediment_id not in sediment_ids:
|
| 610 |
sediment_ids.append(sediment_id)
|
|
|
|
| 611 |
if file_ids:
|
|
|
|
| 612 |
return file_ids, sediment_ids
|
| 613 |
if sediment_ids:
|
|
|
|
| 614 |
return [], sediment_ids
|
|
|
|
| 615 |
time.sleep(4)
|
| 616 |
-
|
|
|
|
| 617 |
|
| 618 |
def _get_file_download_url(self, file_id: str) -> str:
|
| 619 |
"""获取文件下载地址。"""
|
|
@@ -633,15 +533,6 @@ class OpenAIBackendAPI:
|
|
| 633 |
data = response.json()
|
| 634 |
return data.get("download_url") or data.get("url") or ""
|
| 635 |
|
| 636 |
-
def _save_image_bytes(self, image_data: bytes) -> str:
|
| 637 |
-
config.cleanup_old_images()
|
| 638 |
-
file_name = f"{int(time.time())}_{new_uuid().replace('-', '')}.png"
|
| 639 |
-
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
| 640 |
-
file_path = config.images_dir / relative_dir / file_name
|
| 641 |
-
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 642 |
-
file_path.write_bytes(image_data)
|
| 643 |
-
return f"{config.base_url}/images/{relative_dir.as_posix()}/{file_name}"
|
| 644 |
-
|
| 645 |
def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
|
| 646 |
"""把图片结果 id 解析成可下载 URL。"""
|
| 647 |
urls = []
|
|
@@ -714,541 +605,48 @@ class OpenAIBackendAPI:
|
|
| 714 |
})
|
| 715 |
return urls
|
| 716 |
|
| 717 |
-
def
|
| 718 |
-
|
| 719 |
-
|
| 720 |
-
|
| 721 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 722 |
for url in urls:
|
| 723 |
response = self.session.get(url, timeout=120)
|
| 724 |
ensure_ok(response, "image_download")
|
| 725 |
-
|
| 726 |
-
|
| 727 |
-
else:
|
| 728 |
-
data.append({"url": self._save_image_bytes(response.content)})
|
| 729 |
-
return {"created": int(time.time()), "data": data}
|
| 730 |
|
| 731 |
-
def
|
| 732 |
-
|
| 733 |
-
|
| 734 |
-
|
| 735 |
-
|
| 736 |
-
|
| 737 |
-
|
| 738 |
-
|
| 739 |
-
|
| 740 |
-
|
| 741 |
-
|
| 742 |
-
|
| 743 |
-
|
| 744 |
-
|
| 745 |
-
self._bootstrap()
|
| 746 |
-
requirements = self._get_auth_chat_requirements()
|
| 747 |
-
logger.debug({
|
| 748 |
-
"event": "image_requirements_ready",
|
| 749 |
-
"token_present": bool(requirements.token),
|
| 750 |
-
"proof_token_present": bool(requirements.proof_token),
|
| 751 |
-
"turnstile_token_present": bool(requirements.turnstile_token),
|
| 752 |
-
"so_token_present": bool(requirements.so_token),
|
| 753 |
-
"raw_finalize": requirements.raw_finalize,
|
| 754 |
-
})
|
| 755 |
-
final_prompt = self._build_image_prompt(prompt, size)
|
| 756 |
-
logger.debug({"event": "image_final_prompt", "final_prompt": final_prompt})
|
| 757 |
-
conduit_token = self._prepare_image_conversation(final_prompt, requirements, model)
|
| 758 |
-
logger.debug({"event": "image_conduit_ready", "conduit_token_present": bool(conduit_token)})
|
| 759 |
-
sse = self._start_image_generation(final_prompt, requirements, conduit_token, model, references)
|
| 760 |
-
sse_result = self._parse_image_sse(sse)
|
| 761 |
-
logger.debug({"event": "image_sse_result", "sse_result": sse_result})
|
| 762 |
-
conversation_id = sse_result["conversation_id"]
|
| 763 |
-
file_ids = list(sse_result["file_ids"])
|
| 764 |
-
sediment_ids = list(sse_result["sediment_ids"])
|
| 765 |
-
invalid_file_id_patterns = {"file_upload"}
|
| 766 |
-
file_ids = [fid for fid in file_ids if fid not in invalid_file_id_patterns]
|
| 767 |
-
if conversation_id and not file_ids and not sediment_ids:
|
| 768 |
-
polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
|
| 769 |
-
file_ids.extend([item for item in polled_file_ids if item not in file_ids])
|
| 770 |
-
sediment_ids.extend([item for item in polled_sediment_ids if item not in sediment_ids])
|
| 771 |
-
logger.debug({
|
| 772 |
-
"event": "image_polled_result",
|
| 773 |
-
"conversation_id": conversation_id,
|
| 774 |
-
"file_ids": polled_file_ids,
|
| 775 |
-
"sediment_ids": polled_sediment_ids,
|
| 776 |
-
})
|
| 777 |
-
try:
|
| 778 |
-
conversation = self._get_conversation(conversation_id)
|
| 779 |
-
logger.debug({
|
| 780 |
-
"event": "image_conversation_snapshot",
|
| 781 |
-
"conversation_id": conversation_id,
|
| 782 |
-
"conversation": conversation,
|
| 783 |
-
})
|
| 784 |
-
except Exception as exc:
|
| 785 |
-
logger.debug({
|
| 786 |
-
"event": "image_conversation_snapshot_failed",
|
| 787 |
-
"conversation_id": conversation_id,
|
| 788 |
-
"error": repr(exc),
|
| 789 |
-
})
|
| 790 |
-
logger.debug({
|
| 791 |
-
"event": "image_resolved_ids",
|
| 792 |
-
"conversation_id": conversation_id,
|
| 793 |
-
"file_ids": file_ids,
|
| 794 |
-
"sediment_ids": sediment_ids,
|
| 795 |
-
})
|
| 796 |
-
urls = self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
| 797 |
-
logger.debug({"event": "image_final_urls", "conversation_id": conversation_id, "urls": urls})
|
| 798 |
-
if not urls:
|
| 799 |
-
raise RuntimeError(
|
| 800 |
-
"no downloadable image result found; "
|
| 801 |
-
f"conversation_id={conversation_id}, file_ids={file_ids}, sediment_ids={sediment_ids}"
|
| 802 |
-
)
|
| 803 |
-
return self._image_response(urls, response_format)
|
| 804 |
-
|
| 805 |
-
def _build_codex_response_input(self, prompt: str, images: Optional[list[str]] = None) -> list[Dict[str, Any]]:
|
| 806 |
-
if not images:
|
| 807 |
-
return [{"role": "user", "content": prompt}]
|
| 808 |
-
content = [{"type": "input_text", "text": prompt}]
|
| 809 |
-
for image in images:
|
| 810 |
-
content.append({"type": "input_image", "image_url": self._image_to_data_url(image)})
|
| 811 |
-
return [{"role": "user", "content": content}]
|
| 812 |
-
|
| 813 |
-
def _collect_codex_events(self, prompt: str, model: str = CODEX_IMAGE_MODEL,
|
| 814 |
-
images: Optional[list[str]] = None) -> list[Dict[str, Any]]:
|
| 815 |
-
events = list(self.responses(
|
| 816 |
-
input=self._build_codex_response_input(prompt, images),
|
| 817 |
-
model=model,
|
| 818 |
-
stream=True,
|
| 819 |
-
))
|
| 820 |
-
logger.debug({"event": "codex_responses_events_collected", "event_count": len(events)})
|
| 821 |
-
return events
|
| 822 |
-
|
| 823 |
-
def _codex_image_response(self, events: list[Dict[str, Any]], response_format: str) -> Dict[str, Any]:
|
| 824 |
-
if response_format not in {"url", "b64_json"}:
|
| 825 |
-
raise ValueError("response_format must be 'url' or 'b64_json'")
|
| 826 |
-
image_item = {}
|
| 827 |
-
response_payload = {}
|
| 828 |
-
for event in events:
|
| 829 |
-
if not isinstance(event, dict):
|
| 830 |
-
continue
|
| 831 |
-
if event.get("type") == "response.output_item.done":
|
| 832 |
-
item = event.get("item") or {}
|
| 833 |
-
if item.get("type") == "image_generation_call" and item.get("result"):
|
| 834 |
-
image_item = item
|
| 835 |
-
elif event.get("type") == "response.completed":
|
| 836 |
-
response_payload = event.get("response") or {}
|
| 837 |
-
image_b64 = image_item.get("result", "")
|
| 838 |
-
if not image_b64:
|
| 839 |
-
raise RuntimeError("codex responses did not return image base64 result")
|
| 840 |
-
data = []
|
| 841 |
-
if response_format == "b64_json":
|
| 842 |
-
data.append({"b64_json": image_b64})
|
| 843 |
-
else:
|
| 844 |
-
data.append({"url": self._save_image_bytes(base64.b64decode(image_b64))})
|
| 845 |
-
return {
|
| 846 |
-
"created": response_payload.get("created_at") or int(time.time()),
|
| 847 |
-
"data": data,
|
| 848 |
-
"model": response_payload.get("model") or CODEX_RESPONSE_MODEL,
|
| 849 |
-
"usage": response_payload.get("usage"),
|
| 850 |
-
"tool_usage": response_payload.get("tool_usage"),
|
| 851 |
-
"response_id": response_payload.get("id"),
|
| 852 |
-
"status": response_payload.get("status"),
|
| 853 |
-
"revised_prompt": image_item.get("revised_prompt"),
|
| 854 |
-
"size": image_item.get("size"),
|
| 855 |
-
"output_format": image_item.get("output_format"),
|
| 856 |
-
"response": response_payload,
|
| 857 |
-
}
|
| 858 |
-
|
| 859 |
-
def _run_codex_image_task(self, prompt: str, response_format: str = "url",
|
| 860 |
-
images: Optional[list[str]] = None) -> Dict[str, Any]:
|
| 861 |
-
logger.debug({"event": "codex_image_task_start", "prompt": prompt, "image_count": len(images or [])})
|
| 862 |
-
events = self._collect_codex_events(prompt=prompt, model=CODEX_IMAGE_MODEL, images=images)
|
| 863 |
-
result = self._codex_image_response(events, response_format)
|
| 864 |
-
logger.debug({
|
| 865 |
-
"event": "codex_image_task_done",
|
| 866 |
-
"response_id": result.get("response_id"),
|
| 867 |
-
"status": result.get("status"),
|
| 868 |
-
"usage": result.get("usage"),
|
| 869 |
-
"tool_usage": result.get("tool_usage"),
|
| 870 |
-
})
|
| 871 |
-
return result
|
| 872 |
-
|
| 873 |
-
def _extract_text_from_events(self, events: list[Dict[str, Any]]) -> str:
|
| 874 |
-
"""从 SSE 事件中提取最终 assistant 文本。"""
|
| 875 |
-
for event in reversed(events):
|
| 876 |
-
message = event.get("message") or {}
|
| 877 |
-
if (message.get("author") or {}).get("role") != "assistant":
|
| 878 |
-
continue
|
| 879 |
-
text = self._text_from_message(message)
|
| 880 |
-
if text.strip():
|
| 881 |
-
return text
|
| 882 |
-
return ""
|
| 883 |
-
|
| 884 |
-
@staticmethod
|
| 885 |
-
def _strip_history_prefix(text: str, history_text: str) -> str:
|
| 886 |
-
history_text = str(history_text or "")
|
| 887 |
-
text = str(text or "")
|
| 888 |
-
if history_text and text.startswith(history_text):
|
| 889 |
-
return text[len(history_text):]
|
| 890 |
-
return text
|
| 891 |
-
|
| 892 |
-
def _normalize_assistant_text(self, text: str, history_text: str) -> str:
|
| 893 |
-
return self._strip_history_prefix(text, history_text)
|
| 894 |
-
|
| 895 |
-
def _text_from_message(self, message: Dict[str, Any]) -> str:
|
| 896 |
-
"""从单条 message 结构中提取文本。"""
|
| 897 |
-
content = message.get("content") or {}
|
| 898 |
-
parts = content.get("parts") or []
|
| 899 |
-
if not isinstance(parts, list):
|
| 900 |
-
return ""
|
| 901 |
-
texts = [part for part in parts if isinstance(part, str)]
|
| 902 |
-
if texts:
|
| 903 |
-
return "".join(texts)
|
| 904 |
-
return ""
|
| 905 |
-
|
| 906 |
-
@staticmethod
|
| 907 |
-
def _append_unique(values: list[str], candidates: list[str]) -> None:
|
| 908 |
-
for candidate in candidates:
|
| 909 |
-
if candidate and candidate not in values:
|
| 910 |
-
values.append(candidate)
|
| 911 |
-
|
| 912 |
-
@staticmethod
|
| 913 |
-
def _extract_image_stream_ids(payload: str) -> tuple[list[str], list[str]]:
|
| 914 |
-
file_ids = re.findall(r"(file[-_][A-Za-z0-9]+)", payload)
|
| 915 |
-
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 916 |
-
return file_ids, sediment_ids
|
| 917 |
-
|
| 918 |
-
@staticmethod
|
| 919 |
-
def _extract_image_stream_conversation_id(payload: str) -> str:
|
| 920 |
-
match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 921 |
-
return match.group(1) if match else ""
|
| 922 |
-
|
| 923 |
-
def _next_image_stream_text(self, event: Dict[str, Any], current_text: str) -> str:
|
| 924 |
-
for candidate in (event, event.get("v")):
|
| 925 |
-
if not isinstance(candidate, dict):
|
| 926 |
-
continue
|
| 927 |
-
message = candidate.get("message")
|
| 928 |
-
if not isinstance(message, dict):
|
| 929 |
-
continue
|
| 930 |
-
role = str((message.get("author") or {}).get("role") or "").strip().lower()
|
| 931 |
-
if role == "user":
|
| 932 |
-
continue
|
| 933 |
-
text = self._text_from_message(message)
|
| 934 |
-
if text:
|
| 935 |
-
return text
|
| 936 |
-
return self._apply_text_patch(event, current_text)
|
| 937 |
-
|
| 938 |
-
def stream_image_chat_completions(
|
| 939 |
-
self,
|
| 940 |
-
prompt: str,
|
| 941 |
-
model: str = "gpt-image-2",
|
| 942 |
-
size: str | None = None,
|
| 943 |
-
images: Optional[list[str]] = None,
|
| 944 |
-
) -> Iterator[Dict[str, Any]]:
|
| 945 |
-
if not self.access_token:
|
| 946 |
-
raise RuntimeError("access_token is required for image endpoints")
|
| 947 |
-
|
| 948 |
-
completion_id = f"chatcmpl-{new_uuid()}"
|
| 949 |
-
created = int(time.time())
|
| 950 |
-
current_text = ""
|
| 951 |
-
sent_role = False
|
| 952 |
-
conversation_id = ""
|
| 953 |
-
file_ids: list[str] = []
|
| 954 |
-
sediment_ids: list[str] = []
|
| 955 |
-
|
| 956 |
-
references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images or [], start=1)]
|
| 957 |
self._bootstrap()
|
| 958 |
-
requirements = self.
|
| 959 |
-
|
| 960 |
-
|
| 961 |
-
sse = self._start_image_generation(final_prompt, requirements, conduit_token, model, references)
|
| 962 |
-
try:
|
| 963 |
-
for raw_line in sse.iter_lines():
|
| 964 |
-
if not raw_line:
|
| 965 |
-
continue
|
| 966 |
-
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
|
| 967 |
-
if not line.startswith("data:"):
|
| 968 |
-
continue
|
| 969 |
-
payload = line[5:].strip()
|
| 970 |
-
if not payload:
|
| 971 |
-
continue
|
| 972 |
-
if payload == "[DONE]":
|
| 973 |
-
break
|
| 974 |
-
|
| 975 |
-
if not conversation_id:
|
| 976 |
-
conversation_id = self._extract_image_stream_conversation_id(payload)
|
| 977 |
-
new_file_ids, new_sediment_ids = self._extract_image_stream_ids(payload)
|
| 978 |
-
self._append_unique(file_ids, new_file_ids)
|
| 979 |
-
self._append_unique(sediment_ids, new_sediment_ids)
|
| 980 |
-
|
| 981 |
-
try:
|
| 982 |
-
event = json.loads(payload)
|
| 983 |
-
except json.JSONDecodeError:
|
| 984 |
-
continue
|
| 985 |
-
if not isinstance(event, dict):
|
| 986 |
-
continue
|
| 987 |
-
|
| 988 |
-
conversation_id = str(event.get("conversation_id") or conversation_id)
|
| 989 |
-
value = event.get("v")
|
| 990 |
-
if isinstance(value, dict):
|
| 991 |
-
conversation_id = str(value.get("conversation_id") or conversation_id)
|
| 992 |
-
|
| 993 |
-
next_text = self._next_image_stream_text(event, current_text)
|
| 994 |
-
if next_text == current_text:
|
| 995 |
-
yield {
|
| 996 |
-
"id": completion_id,
|
| 997 |
-
"object": "chat.completion.chunk",
|
| 998 |
-
"created": created,
|
| 999 |
-
"model": model,
|
| 1000 |
-
"choices": [{
|
| 1001 |
-
"index": 0,
|
| 1002 |
-
"delta": {},
|
| 1003 |
-
"finish_reason": None,
|
| 1004 |
-
}],
|
| 1005 |
-
"upstream_event": event,
|
| 1006 |
-
}
|
| 1007 |
-
continue
|
| 1008 |
-
|
| 1009 |
-
delta = next_text[len(current_text):] if next_text.startswith(current_text) else next_text
|
| 1010 |
-
current_text = next_text
|
| 1011 |
-
if not sent_role:
|
| 1012 |
-
sent_role = True
|
| 1013 |
-
yield {
|
| 1014 |
-
"id": completion_id,
|
| 1015 |
-
"object": "chat.completion.chunk",
|
| 1016 |
-
"created": created,
|
| 1017 |
-
"model": model,
|
| 1018 |
-
"choices": [{
|
| 1019 |
-
"index": 0,
|
| 1020 |
-
"delta": {"role": "assistant", "content": delta},
|
| 1021 |
-
"finish_reason": None,
|
| 1022 |
-
}],
|
| 1023 |
-
"upstream_event": event,
|
| 1024 |
-
}
|
| 1025 |
-
continue
|
| 1026 |
-
|
| 1027 |
-
yield {
|
| 1028 |
-
"id": completion_id,
|
| 1029 |
-
"object": "chat.completion.chunk",
|
| 1030 |
-
"created": created,
|
| 1031 |
-
"model": model,
|
| 1032 |
-
"choices": [{
|
| 1033 |
-
"index": 0,
|
| 1034 |
-
"delta": {"content": delta},
|
| 1035 |
-
"finish_reason": None,
|
| 1036 |
-
}],
|
| 1037 |
-
"upstream_event": event,
|
| 1038 |
-
}
|
| 1039 |
-
finally:
|
| 1040 |
-
sse.close()
|
| 1041 |
-
|
| 1042 |
-
invalid_file_id_patterns = {"file_upload"}
|
| 1043 |
-
file_ids = [fid for fid in file_ids if fid not in invalid_file_id_patterns]
|
| 1044 |
-
if conversation_id and not file_ids and not sediment_ids:
|
| 1045 |
-
polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
|
| 1046 |
-
self._append_unique(file_ids, polled_file_ids)
|
| 1047 |
-
self._append_unique(sediment_ids, polled_sediment_ids)
|
| 1048 |
-
|
| 1049 |
-
urls = self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
| 1050 |
-
if not urls:
|
| 1051 |
-
raise RuntimeError(
|
| 1052 |
-
"no downloadable image result found; "
|
| 1053 |
-
f"conversation_id={conversation_id}, file_ids={file_ids}, sediment_ids={sediment_ids}"
|
| 1054 |
-
)
|
| 1055 |
-
|
| 1056 |
-
image_content = build_chat_image_markdown_content(self._image_response(urls, "b64_json"))
|
| 1057 |
-
if not sent_role:
|
| 1058 |
-
sent_role = True
|
| 1059 |
-
yield {
|
| 1060 |
-
"id": completion_id,
|
| 1061 |
-
"object": "chat.completion.chunk",
|
| 1062 |
-
"created": created,
|
| 1063 |
-
"model": model,
|
| 1064 |
-
"choices": [{
|
| 1065 |
-
"index": 0,
|
| 1066 |
-
"delta": {"role": "assistant", "content": image_content},
|
| 1067 |
-
"finish_reason": None,
|
| 1068 |
-
}],
|
| 1069 |
-
}
|
| 1070 |
-
else:
|
| 1071 |
-
yield {
|
| 1072 |
-
"id": completion_id,
|
| 1073 |
-
"object": "chat.completion.chunk",
|
| 1074 |
-
"created": created,
|
| 1075 |
-
"model": model,
|
| 1076 |
-
"choices": [{
|
| 1077 |
-
"index": 0,
|
| 1078 |
-
"delta": {"content": image_content},
|
| 1079 |
-
"finish_reason": None,
|
| 1080 |
-
}],
|
| 1081 |
-
}
|
| 1082 |
-
yield {
|
| 1083 |
-
"id": completion_id,
|
| 1084 |
-
"object": "chat.completion.chunk",
|
| 1085 |
-
"created": created,
|
| 1086 |
-
"model": model,
|
| 1087 |
-
"choices": [{
|
| 1088 |
-
"index": 0,
|
| 1089 |
-
"delta": {},
|
| 1090 |
-
"finish_reason": "stop",
|
| 1091 |
-
}],
|
| 1092 |
-
}
|
| 1093 |
-
|
| 1094 |
-
def _apply_text_patch(self, event: Dict[str, Any], current_text: str, history_text: str = "") -> str:
|
| 1095 |
-
"""从 patch 事件里恢复最新文本。"""
|
| 1096 |
-
operations = event.get("v")
|
| 1097 |
-
if not isinstance(operations, list):
|
| 1098 |
-
return current_text
|
| 1099 |
-
text = current_text
|
| 1100 |
-
for item in operations:
|
| 1101 |
-
if not isinstance(item, dict):
|
| 1102 |
-
continue
|
| 1103 |
-
if item.get("p") != "/message/content/parts/0":
|
| 1104 |
-
continue
|
| 1105 |
-
if item.get("o") == "append":
|
| 1106 |
-
text += str(item.get("v", ""))
|
| 1107 |
-
if item.get("o") == "replace":
|
| 1108 |
-
text = self._normalize_assistant_text(str(item.get("v", "")), history_text)
|
| 1109 |
-
return text
|
| 1110 |
-
|
| 1111 |
-
def _next_assistant_text(self, event: Dict[str, Any], current_text: str, history_text: str = "") -> str:
|
| 1112 |
-
"""从 SSE 事件中推导当前 assistant 全量文本。"""
|
| 1113 |
-
message = event.get("message")
|
| 1114 |
-
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 1115 |
-
text = self._text_from_message(message)
|
| 1116 |
-
if text:
|
| 1117 |
-
return self._normalize_assistant_text(text, history_text)
|
| 1118 |
-
|
| 1119 |
-
value = event.get("v")
|
| 1120 |
-
if isinstance(value, dict):
|
| 1121 |
-
message = value.get("message")
|
| 1122 |
-
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 1123 |
-
text = self._text_from_message(message)
|
| 1124 |
-
if text:
|
| 1125 |
-
return self._normalize_assistant_text(text, history_text)
|
| 1126 |
-
|
| 1127 |
-
return self._apply_text_patch(event, current_text, history_text)
|
| 1128 |
-
|
| 1129 |
-
def _encoding_for_model(self, model: str):
|
| 1130 |
-
"""按模型选择 tokenizer,失败时回退到通用编码。"""
|
| 1131 |
-
try:
|
| 1132 |
-
return tiktoken.encoding_for_model(model)
|
| 1133 |
-
except KeyError:
|
| 1134 |
-
try:
|
| 1135 |
-
return tiktoken.get_encoding("o200k_base")
|
| 1136 |
-
except KeyError:
|
| 1137 |
-
return tiktoken.get_encoding("cl100k_base")
|
| 1138 |
-
|
| 1139 |
-
def _count_message_tokens(self, messages: list[Dict[str, Any]], model: str) -> int:
|
| 1140 |
-
"""估算标准 chat messages 的 token 数。"""
|
| 1141 |
-
encoding = self._encoding_for_model(model)
|
| 1142 |
-
tokens_per_message = 3
|
| 1143 |
-
total = 0
|
| 1144 |
-
for message in messages:
|
| 1145 |
-
total += tokens_per_message
|
| 1146 |
-
for key, value in message.items():
|
| 1147 |
-
if not isinstance(value, str):
|
| 1148 |
-
continue
|
| 1149 |
-
total += len(encoding.encode(value))
|
| 1150 |
-
if key == "name":
|
| 1151 |
-
total += 1
|
| 1152 |
-
return total + 3
|
| 1153 |
-
|
| 1154 |
-
def _count_text_tokens(self, text: str, model: str) -> int:
|
| 1155 |
-
"""估算单段文本的 token 数。"""
|
| 1156 |
-
return len(self._encoding_for_model(model).encode(text))
|
| 1157 |
-
|
| 1158 |
-
def _extract_message_text(self, content: Any) -> str:
|
| 1159 |
-
"""从 OpenAI/Anthropic 风格的 content 字段里提取纯文本。"""
|
| 1160 |
-
if isinstance(content, str):
|
| 1161 |
-
return content
|
| 1162 |
-
if isinstance(content, list):
|
| 1163 |
-
parts = []
|
| 1164 |
-
for item in content:
|
| 1165 |
-
if isinstance(item, str):
|
| 1166 |
-
parts.append(item)
|
| 1167 |
-
continue
|
| 1168 |
-
if not isinstance(item, dict):
|
| 1169 |
-
raise RuntimeError("only string or text content blocks are supported")
|
| 1170 |
-
block_type = item.get("type")
|
| 1171 |
-
if block_type in {"text", "input_text", "output_text"}:
|
| 1172 |
-
parts.append(str(item.get("text", "")))
|
| 1173 |
-
continue
|
| 1174 |
-
raise RuntimeError(f"unsupported content block type: {block_type}")
|
| 1175 |
-
return "".join(parts)
|
| 1176 |
-
if content is None:
|
| 1177 |
-
return ""
|
| 1178 |
-
raise RuntimeError("only string or text content blocks are supported")
|
| 1179 |
-
|
| 1180 |
-
def _normalize_messages(self, messages: list[Dict[str, Any]], system: Any = None) -> list[Dict[str, Any]]:
|
| 1181 |
-
"""把 OpenAI/Anthropic 风格的消息统一整理成标准 chat messages。"""
|
| 1182 |
-
normalized = []
|
| 1183 |
-
if system is not None:
|
| 1184 |
-
system_text = self._extract_message_text(system)
|
| 1185 |
-
if system_text:
|
| 1186 |
-
normalized.append({"role": "system", "content": system_text})
|
| 1187 |
-
|
| 1188 |
-
for item in messages:
|
| 1189 |
-
normalized.append({
|
| 1190 |
-
"role": item.get("role", "user"),
|
| 1191 |
-
"content": self._extract_message_text(item.get("content", "")),
|
| 1192 |
-
})
|
| 1193 |
-
return normalized
|
| 1194 |
-
|
| 1195 |
-
def _assistant_history_text(self, messages: list[Dict[str, Any]]) -> str:
|
| 1196 |
-
"""获取输入历史里所有 assistant 文本拼接结果。"""
|
| 1197 |
-
parts = []
|
| 1198 |
-
for message in messages:
|
| 1199 |
-
if message.get("role") != "assistant":
|
| 1200 |
-
continue
|
| 1201 |
-
content = message.get("content", "")
|
| 1202 |
-
if isinstance(content, str) and content:
|
| 1203 |
-
parts.append(content)
|
| 1204 |
-
return "".join(parts)
|
| 1205 |
-
|
| 1206 |
-
def _assistant_history_messages(self, messages: list[Dict[str, Any]]) -> list[str]:
|
| 1207 |
-
parts = []
|
| 1208 |
-
for message in messages:
|
| 1209 |
-
if message.get("role") != "assistant":
|
| 1210 |
-
continue
|
| 1211 |
-
content = message.get("content", "")
|
| 1212 |
-
if isinstance(content, str) and content:
|
| 1213 |
-
parts.append(content)
|
| 1214 |
-
return parts
|
| 1215 |
-
|
| 1216 |
-
def _event_assistant_text(self, event: Dict[str, Any], history_text: str = "") -> str:
|
| 1217 |
-
message = event.get("message")
|
| 1218 |
-
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 1219 |
-
return self._normalize_assistant_text(self._text_from_message(message), history_text)
|
| 1220 |
-
|
| 1221 |
-
value = event.get("v")
|
| 1222 |
-
if isinstance(value, dict):
|
| 1223 |
-
message = value.get("message")
|
| 1224 |
-
if isinstance(message, dict) and (message.get("author") or {}).get("role") == "assistant":
|
| 1225 |
-
return self._normalize_assistant_text(self._text_from_message(message), history_text)
|
| 1226 |
-
|
| 1227 |
-
return ""
|
| 1228 |
-
|
| 1229 |
-
def _last_event(self, events: list[Dict[str, Any]]) -> Dict[str, Any]:
|
| 1230 |
-
"""返回最后一个非终止事件,方便排查问题。"""
|
| 1231 |
-
for event in reversed(events):
|
| 1232 |
-
if not event.get("done"):
|
| 1233 |
-
return event
|
| 1234 |
-
return {}
|
| 1235 |
-
|
| 1236 |
-
def _conversation_payload_debug(self, payload: Dict[str, Any]) -> Dict[str, Any]:
|
| 1237 |
-
raw = json.dumps(payload, ensure_ascii=False, separators=(",", ":"))
|
| 1238 |
-
messages = payload.get("messages") if isinstance(payload.get("messages"), list) else []
|
| 1239 |
-
details = []
|
| 1240 |
-
for item in messages:
|
| 1241 |
-
content = item.get("content") if isinstance(item, dict) else {}
|
| 1242 |
-
parts = content.get("parts") if isinstance(content, dict) else []
|
| 1243 |
-
text = "\n".join(str(part) for part in parts) if isinstance(parts, list) else ""
|
| 1244 |
-
details.append({"role": (item.get("author") or {}).get("role") if isinstance(item, dict) and isinstance(item.get("author"), dict) else "", "chars": len(text), "preview": text[:120]})
|
| 1245 |
-
return {"bytes": len(raw.encode("utf-8")), "chars": len(raw), "message_count": len(messages), "messages": details}
|
| 1246 |
-
|
| 1247 |
-
def _stream_events(self, path: str, requirements: ChatRequirements, payload: Dict[str, Any]) -> Iterator[
|
| 1248 |
-
Dict[str, Any]]:
|
| 1249 |
-
"""向 conversation 接口发起请求,并逐条产出 SSE 事件。"""
|
| 1250 |
-
payload_debug = self._conversation_payload_debug(payload)
|
| 1251 |
-
logger.info({"event": "conversation_payload_debug", "path": path, **payload_debug})
|
| 1252 |
response = self.session.post(
|
| 1253 |
self.base_url + path,
|
| 1254 |
headers=self._conversation_headers(path, requirements),
|
|
@@ -1256,10 +654,29 @@ class OpenAIBackendAPI:
|
|
| 1256 |
timeout=300,
|
| 1257 |
stream=True,
|
| 1258 |
)
|
| 1259 |
-
if response.status_code >= 400:
|
| 1260 |
-
logger.warning({"event": "conversation_request_failed", "path": path, "status_code": response.status_code, "payload": payload_debug, "response_text": response.text[:500]})
|
| 1261 |
ensure_ok(response, path)
|
| 1262 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1263 |
|
| 1264 |
def _bootstrap(self) -> None:
|
| 1265 |
"""预热首页,并提取 PoW 相关脚本引用。"""
|
|
@@ -1273,13 +690,11 @@ class OpenAIBackendAPI:
|
|
| 1273 |
if not self.pow_script_sources:
|
| 1274 |
self.pow_script_sources = [DEFAULT_POW_SCRIPT]
|
| 1275 |
|
| 1276 |
-
def _get_chat_requirements(self
|
| 1277 |
"""获取当前模式对话所需的 sentinel token。"""
|
| 1278 |
-
|
| 1279 |
-
|
| 1280 |
-
|
| 1281 |
-
context = "auth_chat_requirements" if authenticated else "noauth_chat_requirements"
|
| 1282 |
-
body = {"p": self._build_requirements_token()}
|
| 1283 |
response = self.session.post(
|
| 1284 |
self.base_url + path,
|
| 1285 |
headers=self._headers(path, {"Content-Type": "application/json"}),
|
|
@@ -1287,292 +702,48 @@ class OpenAIBackendAPI:
|
|
| 1287 |
timeout=30,
|
| 1288 |
)
|
| 1289 |
ensure_ok(response, context)
|
| 1290 |
-
requirements = self._build_requirements(response.json(), "" if
|
| 1291 |
if not requirements.token:
|
| 1292 |
-
message = "missing auth chat requirements token" if
|
| 1293 |
raise RuntimeError(f"{message}: {requirements.raw_finalize}")
|
| 1294 |
return requirements
|
| 1295 |
|
| 1296 |
-
def
|
| 1297 |
-
|
| 1298 |
-
|
| 1299 |
-
|
| 1300 |
-
return self._get_chat_requirements(authenticated=False)
|
| 1301 |
|
| 1302 |
-
def
|
| 1303 |
-
"""
|
| 1304 |
-
if authenticated and not self.access_token:
|
| 1305 |
-
raise RuntimeError("access_token is required for auth models")
|
| 1306 |
self._bootstrap()
|
| 1307 |
-
path = "/backend-api/models?history_and_training_disabled=false" if
|
| 1308 |
"/backend-anon/models?iim=false&is_gizmo=false"
|
| 1309 |
)
|
| 1310 |
-
route = "/backend-api/models" if
|
| 1311 |
-
context = "auth_models" if
|
| 1312 |
response = self.session.get(
|
| 1313 |
self.base_url + path,
|
| 1314 |
headers=self._headers(route),
|
| 1315 |
timeout=30,
|
| 1316 |
)
|
| 1317 |
ensure_ok(response, context)
|
| 1318 |
-
|
| 1319 |
-
|
| 1320 |
-
|
| 1321 |
-
|
| 1322 |
-
return "/backend-api/conversation", "Asia/Shanghai"
|
| 1323 |
-
return "/backend-anon/conversation", "America/Los_Angeles"
|
| 1324 |
-
|
| 1325 |
-
def _complete_chat(self, messages: list[Dict[str, Any]], model: str) -> Dict[str, Any]:
|
| 1326 |
-
self._bootstrap()
|
| 1327 |
-
requirements = self._get_chat_requirements(authenticated=bool(self.access_token))
|
| 1328 |
-
path, timezone = self._chat_target()
|
| 1329 |
-
events = list(self._stream_events(path, requirements, self._conversation_payload(messages, model, timezone)))
|
| 1330 |
-
history_assistant_text = self._assistant_history_text(messages)
|
| 1331 |
-
return {
|
| 1332 |
-
"requirements": requirements,
|
| 1333 |
-
"prepare": {},
|
| 1334 |
-
"events": events,
|
| 1335 |
-
"last_event": self._last_event(events),
|
| 1336 |
-
"text": self._strip_history_prefix(self._extract_text_from_events(events), history_assistant_text),
|
| 1337 |
-
}
|
| 1338 |
-
|
| 1339 |
-
def list_models(self) -> Dict[str, Any]:
|
| 1340 |
-
"""返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
|
| 1341 |
-
return self._normalize_models(self._get_models_raw(authenticated=bool(self.access_token)))
|
| 1342 |
-
|
| 1343 |
-
def images_generations(self, prompt: str, model: str = "gpt-image-2", size: str | None = None,
|
| 1344 |
-
response_format: str = "url") -> Dict[str, Any]:
|
| 1345 |
-
"""返回 OpenAI `/v1/images/generations` 风格结果。"""
|
| 1346 |
-
if self._is_codex_image_model(model):
|
| 1347 |
-
return self._run_codex_image_task(prompt, response_format=response_format)
|
| 1348 |
-
return self._run_image_task(prompt, model, size, response_format=response_format)
|
| 1349 |
-
|
| 1350 |
-
def images_edits(self, image: str | list[str], prompt: str, model: str = "gpt-image-2", size: str | None = None,
|
| 1351 |
-
response_format: str = "url") -> Dict[str, Any]:
|
| 1352 |
-
"""返回 OpenAI `/v1/images/edits` 风格结果。"""
|
| 1353 |
-
images = [image] if isinstance(image, str) else image
|
| 1354 |
-
if not images:
|
| 1355 |
-
raise ValueError("image is required for image edits")
|
| 1356 |
-
if self._is_codex_image_model(model):
|
| 1357 |
-
return self._run_codex_image_task(prompt, response_format=response_format, images=images)
|
| 1358 |
-
return self._run_image_task(prompt, model, size, images=images, response_format=response_format)
|
| 1359 |
-
|
| 1360 |
-
def _chat_completion_response(self, model: str, messages: list[Dict[str, Any]], text: str) -> Dict[str, Any]:
|
| 1361 |
-
"""把对话结果整理成 OpenAI `/v1/chat/completions` 风格结构。"""
|
| 1362 |
-
prompt_tokens = self._count_message_tokens(messages, model)
|
| 1363 |
-
completion_tokens = self._count_text_tokens(text, model)
|
| 1364 |
-
return {
|
| 1365 |
-
"id": f"chatcmpl-{new_uuid()}",
|
| 1366 |
-
"object": "chat.completion",
|
| 1367 |
-
"created": int(time.time()),
|
| 1368 |
-
"model": model,
|
| 1369 |
-
"choices": [{
|
| 1370 |
-
"index": 0,
|
| 1371 |
-
"message": {
|
| 1372 |
-
"role": "assistant",
|
| 1373 |
-
"content": text,
|
| 1374 |
-
},
|
| 1375 |
-
"finish_reason": "stop",
|
| 1376 |
-
}],
|
| 1377 |
-
"usage": {
|
| 1378 |
-
"prompt_tokens": prompt_tokens,
|
| 1379 |
-
"completion_tokens": completion_tokens,
|
| 1380 |
-
"total_tokens": prompt_tokens + completion_tokens,
|
| 1381 |
-
},
|
| 1382 |
-
}
|
| 1383 |
-
|
| 1384 |
-
def _stream_chat_completions(self, messages: list[Dict[str, Any]], model: str = "auto") -> Iterator[Dict[str, Any]]:
|
| 1385 |
-
"""返回 OpenAI `/v1/chat/completions` 风格的流式 chunk。"""
|
| 1386 |
-
completion_id = f"chatcmpl-{new_uuid()}"
|
| 1387 |
-
created = int(time.time())
|
| 1388 |
-
history_assistant_text = self._assistant_history_text(messages)
|
| 1389 |
-
history_assistant_messages = self._assistant_history_messages(messages)
|
| 1390 |
-
history_assistant_index = 0
|
| 1391 |
-
current_text = ""
|
| 1392 |
-
sent_role = False
|
| 1393 |
-
self._bootstrap()
|
| 1394 |
-
requirements = self._get_chat_requirements(authenticated=bool(self.access_token))
|
| 1395 |
-
path, timezone = self._chat_target()
|
| 1396 |
-
payload = self._conversation_payload(messages, model, timezone)
|
| 1397 |
-
|
| 1398 |
-
for event in self._stream_events(path, requirements, payload):
|
| 1399 |
-
if event.get("done"):
|
| 1400 |
-
break
|
| 1401 |
-
event_assistant_text = self._event_assistant_text(event, history_assistant_text)
|
| 1402 |
-
if (
|
| 1403 |
-
history_assistant_index < len(history_assistant_messages)
|
| 1404 |
-
and event_assistant_text
|
| 1405 |
-
and event_assistant_text == history_assistant_messages[history_assistant_index]
|
| 1406 |
-
):
|
| 1407 |
-
history_assistant_index += 1
|
| 1408 |
-
current_text = ""
|
| 1409 |
-
continue
|
| 1410 |
-
next_text = self._next_assistant_text(event, current_text, history_assistant_text)
|
| 1411 |
-
if next_text == current_text:
|
| 1412 |
-
continue
|
| 1413 |
-
delta = next_text[len(current_text):] if next_text.startswith(current_text) else next_text
|
| 1414 |
-
current_text = next_text
|
| 1415 |
-
if not sent_role:
|
| 1416 |
-
sent_role = True
|
| 1417 |
-
yield {
|
| 1418 |
-
"id": completion_id,
|
| 1419 |
-
"object": "chat.completion.chunk",
|
| 1420 |
-
"created": created,
|
| 1421 |
-
"model": model,
|
| 1422 |
-
"choices": [{
|
| 1423 |
-
"index": 0,
|
| 1424 |
-
"delta": {"role": "assistant", "content": delta},
|
| 1425 |
-
"finish_reason": None,
|
| 1426 |
-
}],
|
| 1427 |
-
}
|
| 1428 |
-
continue
|
| 1429 |
-
yield {
|
| 1430 |
-
"id": completion_id,
|
| 1431 |
-
"object": "chat.completion.chunk",
|
| 1432 |
-
"created": created,
|
| 1433 |
-
"model": model,
|
| 1434 |
-
"choices": [{
|
| 1435 |
-
"index": 0,
|
| 1436 |
-
"delta": {"content": delta},
|
| 1437 |
-
"finish_reason": None,
|
| 1438 |
-
}],
|
| 1439 |
-
}
|
| 1440 |
-
|
| 1441 |
-
if not sent_role:
|
| 1442 |
-
yield {
|
| 1443 |
-
"id": completion_id,
|
| 1444 |
-
"object": "chat.completion.chunk",
|
| 1445 |
-
"created": created,
|
| 1446 |
-
"model": model,
|
| 1447 |
-
"choices": [{
|
| 1448 |
-
"index": 0,
|
| 1449 |
-
"delta": {"role": "assistant", "content": ""},
|
| 1450 |
-
"finish_reason": None,
|
| 1451 |
-
}],
|
| 1452 |
-
}
|
| 1453 |
-
yield {
|
| 1454 |
-
"id": completion_id,
|
| 1455 |
-
"object": "chat.completion.chunk",
|
| 1456 |
-
"created": created,
|
| 1457 |
-
"model": model,
|
| 1458 |
-
"choices": [{
|
| 1459 |
-
"index": 0,
|
| 1460 |
-
"delta": {},
|
| 1461 |
-
"finish_reason": "stop",
|
| 1462 |
-
}],
|
| 1463 |
-
}
|
| 1464 |
-
|
| 1465 |
-
def _anthropic_message_response(self, model: str, messages: list[Dict[str, Any]], text: str, tools: Any = None) -> Dict[str, Any]:
|
| 1466 |
-
"""把对话结果整理成 Anthropic `/v1/messages` 风格结构。"""
|
| 1467 |
-
return message_response(model, text, self._count_message_tokens(messages, model), self._count_text_tokens(text, model), tools)
|
| 1468 |
-
|
| 1469 |
-
def _stream_anthropic_messages(self, messages: list[Dict[str, Any]], model: str = "auto", tools: Any = None) -> Iterator[
|
| 1470 |
-
Dict[str, Any]]:
|
| 1471 |
-
"""返回 Anthropic `/v1/messages` 风格的流式事件。"""
|
| 1472 |
-
yield from stream_events(self._stream_chat_completions(messages, model), model, self._count_message_tokens(messages, model), lambda text: self._count_text_tokens(text, model), tools)
|
| 1473 |
-
|
| 1474 |
-
def _iter_response_events(self, response: requests.Response) -> Iterator[Dict[str, Any]]:
|
| 1475 |
-
"""按 Responses 接口事件格式直通输出,不额外包装标准事件。"""
|
| 1476 |
-
for raw_line in response.iter_lines():
|
| 1477 |
-
if not raw_line:
|
| 1478 |
-
continue
|
| 1479 |
-
line = raw_line.decode("utf-8", errors="ignore")
|
| 1480 |
-
if not line.startswith("data:"):
|
| 1481 |
continue
|
| 1482 |
-
|
| 1483 |
-
if not
|
| 1484 |
continue
|
| 1485 |
-
|
| 1486 |
-
|
| 1487 |
-
|
| 1488 |
-
|
| 1489 |
-
|
| 1490 |
-
|
| 1491 |
-
|
| 1492 |
-
|
| 1493 |
-
|
| 1494 |
-
stream: bool = False,
|
| 1495 |
-
store: bool = False,
|
| 1496 |
-
) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
|
| 1497 |
-
"""返回 `/v1/responses` 风格结果,底层走 `/backend-api/codex/responses`。"""
|
| 1498 |
-
if not self.access_token:
|
| 1499 |
-
raise RuntimeError("access_token is required for responses")
|
| 1500 |
-
path = "/backend-api/codex/responses"
|
| 1501 |
-
token_info = self._get_token_info()
|
| 1502 |
-
input_items = [{"role": "user", "content": input}] if isinstance(input, str) else input
|
| 1503 |
-
effective_model = CODEX_RESPONSE_MODEL if self._is_codex_image_model(model) else model
|
| 1504 |
-
effective_tools = tools
|
| 1505 |
-
if self._is_codex_image_model(model) and not effective_tools:
|
| 1506 |
-
effective_tools = [{"type": "image_generation", "output_format": "png"}]
|
| 1507 |
-
payload = {
|
| 1508 |
-
"model": effective_model,
|
| 1509 |
-
"input": input_items,
|
| 1510 |
-
"tools": effective_tools or [],
|
| 1511 |
-
"instructions": instructions,
|
| 1512 |
-
"tool_choice": tool_choice,
|
| 1513 |
-
"stream": stream,
|
| 1514 |
-
"store": store,
|
| 1515 |
-
}
|
| 1516 |
-
logger.debug({
|
| 1517 |
-
"event": "responses_start",
|
| 1518 |
-
"model": model,
|
| 1519 |
-
"effective_model": effective_model,
|
| 1520 |
-
"stream": stream,
|
| 1521 |
-
"input_count": len(input_items),
|
| 1522 |
-
"tool_count": len(payload["tools"]),
|
| 1523 |
-
"token_info": token_info,
|
| 1524 |
-
})
|
| 1525 |
-
headers = {
|
| 1526 |
-
"Authorization": f"Bearer {self.access_token}",
|
| 1527 |
-
"user-agent": (
|
| 1528 |
-
"codex-tui/0.122.0 (Manjaro 26.1.0-pre; x86_64) "
|
| 1529 |
-
"vscode/3.0.12 (codex-tui; 0.122.0)"
|
| 1530 |
-
),
|
| 1531 |
-
"version": "0.122.0",
|
| 1532 |
-
"originator": "codex_cli_rs",
|
| 1533 |
-
"session_id": "test-session",
|
| 1534 |
-
"accept": "text/event-stream" if stream else "application/json",
|
| 1535 |
-
"Content-Type": "application/json",
|
| 1536 |
-
}
|
| 1537 |
-
if token_info.get("chatgpt_account_id"):
|
| 1538 |
-
headers["chatgpt-account-id"] = token_info["chatgpt_account_id"]
|
| 1539 |
-
response = self.session.post(
|
| 1540 |
-
self.base_url + path,
|
| 1541 |
-
headers=headers,
|
| 1542 |
-
json=payload,
|
| 1543 |
-
timeout=300,
|
| 1544 |
-
stream=stream,
|
| 1545 |
-
)
|
| 1546 |
-
if response.status_code >= 400:
|
| 1547 |
-
logger.debug({
|
| 1548 |
-
"event": "responses_failed",
|
| 1549 |
-
"status_code": response.status_code,
|
| 1550 |
-
"headers": dict(response.headers),
|
| 1551 |
-
"body": response.text,
|
| 1552 |
})
|
| 1553 |
-
|
| 1554 |
-
|
| 1555 |
-
return self._iter_response_events(response)
|
| 1556 |
-
return response.json()
|
| 1557 |
-
|
| 1558 |
-
def chat_completions(
|
| 1559 |
-
self,
|
| 1560 |
-
messages: list[Dict[str, Any]],
|
| 1561 |
-
model: str = "auto",
|
| 1562 |
-
stream: bool = False,
|
| 1563 |
-
) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
|
| 1564 |
-
"""返回 OpenAI `/v1/chat/completions` 风格结果,支持 stream 模式。"""
|
| 1565 |
-
normalized_messages = self._normalize_messages(messages)
|
| 1566 |
-
if stream:
|
| 1567 |
-
return self._stream_chat_completions(normalized_messages, model)
|
| 1568 |
-
result = self._complete_chat(normalized_messages, model)
|
| 1569 |
-
return self._chat_completion_response(model, normalized_messages, result["text"])
|
| 1570 |
-
|
| 1571 |
-
def messages(self, messages: list[Dict[str, Any]], model: str = "auto", stream: bool = False,
|
| 1572 |
-
system: Any = None, tools: Any = None) -> Dict[str, Any] | Iterator[Dict[str, Any]]:
|
| 1573 |
-
"""返回 Anthropic `/v1/messages` 风格结果,支持 stream 模式。"""
|
| 1574 |
-
normalized_messages = self._normalize_messages(messages, system)
|
| 1575 |
-
if stream:
|
| 1576 |
-
return self._stream_anthropic_messages(normalized_messages, model, tools)
|
| 1577 |
-
result = self._complete_chat(normalized_messages, model)
|
| 1578 |
-
return self._anthropic_message_response(model, normalized_messages, result["text"], tools)
|
|
|
|
| 1 |
import base64
|
|
|
|
| 2 |
import os
|
| 3 |
import re
|
| 4 |
import time
|
|
|
|
| 7 |
from pathlib import Path
|
| 8 |
from typing import Any, Dict, Iterator, Optional
|
| 9 |
|
|
|
|
| 10 |
from curl_cffi import requests
|
| 11 |
from PIL import Image
|
| 12 |
|
| 13 |
from services.account_service import account_service
|
|
|
|
|
|
|
| 14 |
from services.proxy_service import proxy_settings
|
| 15 |
+
from utils.helper import ensure_ok, iter_sse_payloads, new_uuid
|
| 16 |
from utils.log import logger
|
| 17 |
from utils.pow import build_legacy_requirements_token, build_proof_token, parse_pow_resources
|
| 18 |
from utils.turnstile import solve_turnstile_token
|
|
|
|
| 32 |
DEFAULT_CLIENT_BUILD_NUMBER = "5955942"
|
| 33 |
DEFAULT_POW_SCRIPT = "https://chatgpt.com/backend-api/sentinel/sdk.js"
|
| 34 |
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
|
|
|
| 35 |
|
| 36 |
|
| 37 |
class OpenAIBackendAPI:
|
|
|
|
| 42 |
例如 `/backend-api/sentinel/chat-requirements`、`/backend-api/conversation`
|
| 43 |
- 不传 `access_token` 时,会走未登录链路
|
| 44 |
例如 `/backend-anon/sentinel/chat-requirements`、`/backend-anon/conversation`
|
| 45 |
+
- `stream_conversation()` 是底层统一流式入口
|
| 46 |
+
- 协议兼容转换放在 `services.protocol`
|
| 47 |
"""
|
| 48 |
|
| 49 |
def __init__(self, access_token: str = "") -> None:
|
|
|
|
| 150 |
"Upgrade-Insecure-Requests": "1",
|
| 151 |
}
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
def _build_requirements(self, data: Dict[str, Any], source_p: str = "") -> ChatRequirements:
|
| 154 |
"""把 sentinel 响应整理成后续对话需要的 token 集合。"""
|
| 155 |
if (data.get("arkose") or {}).get("required"):
|
|
|
|
| 241 |
},
|
| 242 |
}
|
| 243 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 244 |
def _image_model_slug(self, model: str) -> str:
|
| 245 |
"""把标准图片模型名映射到底层 model slug。"""
|
| 246 |
model = str(model or "").strip()
|
| 247 |
if not model:
|
| 248 |
return "auto"
|
| 249 |
+
if model == "gpt-image-2":
|
| 250 |
return "gpt-5-3"
|
| 251 |
if model == CODEX_IMAGE_MODEL:
|
| 252 |
return model
|
| 253 |
return "auto"
|
| 254 |
|
|
|
|
|
|
|
|
|
|
| 255 |
def _image_headers(self, path: str, requirements: ChatRequirements, conduit_token: str = "", accept: str = "*/*") -> \
|
| 256 |
Dict[str, str]:
|
| 257 |
"""构造图片链路请求头。"""
|
|
|
|
| 314 |
payload = image.split(",", 1)[1] if image.startswith("data:") and "," in image else image
|
| 315 |
return base64.b64decode(payload)
|
| 316 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 317 |
def _upload_image(self, image: str, file_name: str = "image.png") -> Dict[str, Any]:
|
| 318 |
"""上传一张 base64 图片,返回底层文件元数据。"""
|
| 319 |
data = self._decode_image_base64(image)
|
|
|
|
| 447 |
ensure_ok(response, path)
|
| 448 |
return response
|
| 449 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 450 |
def _get_conversation(self, conversation_id: str) -> Dict[str, Any]:
|
| 451 |
"""获取完整 conversation 详情。"""
|
| 452 |
path = f"/backend-api/conversation/{conversation_id}"
|
|
|
|
| 490 |
def _poll_image_results(self, conversation_id: str, timeout_secs: float = 120.0) -> tuple[list[str], list[str]]:
|
| 491 |
"""轮询 conversation,直到拿到图片文件 id 或超时。"""
|
| 492 |
start = time.time()
|
| 493 |
+
attempt = 0
|
| 494 |
+
logger.info({"event": "image_poll_start", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
|
| 495 |
while time.time() - start < timeout_secs:
|
| 496 |
+
attempt += 1
|
| 497 |
conversation = self._get_conversation(conversation_id)
|
| 498 |
file_ids, sediment_ids = [], []
|
| 499 |
for record in self._extract_image_tool_records(conversation):
|
|
|
|
| 503 |
for sediment_id in record["sediment_ids"]:
|
| 504 |
if sediment_id not in sediment_ids:
|
| 505 |
sediment_ids.append(sediment_id)
|
| 506 |
+
logger.debug({"event": "image_poll_check", "conversation_id": conversation_id, "attempt": attempt, "file_ids": file_ids, "sediment_ids": sediment_ids})
|
| 507 |
if file_ids:
|
| 508 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": file_ids, "sediment_ids": sediment_ids})
|
| 509 |
return file_ids, sediment_ids
|
| 510 |
if sediment_ids:
|
| 511 |
+
logger.info({"event": "image_poll_hit", "conversation_id": conversation_id, "file_ids": [], "sediment_ids": sediment_ids})
|
| 512 |
return [], sediment_ids
|
| 513 |
+
logger.debug({"event": "image_poll_wait", "conversation_id": conversation_id, "elapsed_secs": round(time.time() - start, 1)})
|
| 514 |
time.sleep(4)
|
| 515 |
+
logger.info({"event": "image_poll_timeout", "conversation_id": conversation_id, "timeout_secs": timeout_secs})
|
| 516 |
+
return [], []
|
| 517 |
|
| 518 |
def _get_file_download_url(self, file_id: str) -> str:
|
| 519 |
"""获取文件下载地址。"""
|
|
|
|
| 533 |
data = response.json()
|
| 534 |
return data.get("download_url") or data.get("url") or ""
|
| 535 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 536 |
def _resolve_image_urls(self, conversation_id: str, file_ids: list[str], sediment_ids: list[str]) -> list[str]:
|
| 537 |
"""把图片结果 id 解析成可下载 URL。"""
|
| 538 |
urls = []
|
|
|
|
| 605 |
})
|
| 606 |
return urls
|
| 607 |
|
| 608 |
+
def resolve_conversation_image_urls(
|
| 609 |
+
self,
|
| 610 |
+
conversation_id: str,
|
| 611 |
+
file_ids: list[str],
|
| 612 |
+
sediment_ids: list[str],
|
| 613 |
+
poll: bool = True,
|
| 614 |
+
) -> list[str]:
|
| 615 |
+
file_ids = [item for item in file_ids if item != "file_upload"]
|
| 616 |
+
sediment_ids = list(sediment_ids)
|
| 617 |
+
if poll and conversation_id and not file_ids and not sediment_ids:
|
| 618 |
+
logger.info({"event": "image_resolve_poll_needed", "conversation_id": conversation_id})
|
| 619 |
+
polled_file_ids, polled_sediment_ids = self._poll_image_results(conversation_id)
|
| 620 |
+
file_ids.extend(item for item in polled_file_ids if item and item not in file_ids)
|
| 621 |
+
sediment_ids.extend(item for item in polled_sediment_ids if item and item not in sediment_ids)
|
| 622 |
+
return self._resolve_image_urls(conversation_id, file_ids, sediment_ids)
|
| 623 |
+
|
| 624 |
+
def download_image_bytes(self, urls: list[str]) -> list[bytes]:
|
| 625 |
+
images = []
|
| 626 |
for url in urls:
|
| 627 |
response = self.session.get(url, timeout=120)
|
| 628 |
ensure_ok(response, "image_download")
|
| 629 |
+
images.append(response.content)
|
| 630 |
+
return images
|
|
|
|
|
|
|
|
|
|
| 631 |
|
| 632 |
+
def stream_conversation(
|
| 633 |
+
self,
|
| 634 |
+
messages: Optional[list[Dict[str, Any]]] = None,
|
| 635 |
+
model: str = "auto",
|
| 636 |
+
prompt: str = "",
|
| 637 |
+
images: Optional[list[str]] = None,
|
| 638 |
+
system_hints: Optional[list[str]] = None,
|
| 639 |
+
) -> Iterator[str]:
|
| 640 |
+
system_hints = system_hints or []
|
| 641 |
+
if "picture_v2" in system_hints:
|
| 642 |
+
yield from self._stream_picture_conversation(prompt, model, images or [])
|
| 643 |
+
return
|
| 644 |
+
|
| 645 |
+
normalized = messages or [{"role": "user", "content": prompt}]
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
self._bootstrap()
|
| 647 |
+
requirements = self._get_chat_requirements()
|
| 648 |
+
path, timezone = self._chat_target()
|
| 649 |
+
payload = self._conversation_payload(normalized, model, timezone)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 650 |
response = self.session.post(
|
| 651 |
self.base_url + path,
|
| 652 |
headers=self._conversation_headers(path, requirements),
|
|
|
|
| 654 |
timeout=300,
|
| 655 |
stream=True,
|
| 656 |
)
|
|
|
|
|
|
|
| 657 |
ensure_ok(response, path)
|
| 658 |
+
try:
|
| 659 |
+
yield from iter_sse_payloads(response)
|
| 660 |
+
finally:
|
| 661 |
+
response.close()
|
| 662 |
+
|
| 663 |
+
def _stream_picture_conversation(
|
| 664 |
+
self,
|
| 665 |
+
prompt: str,
|
| 666 |
+
model: str,
|
| 667 |
+
images: list[str],
|
| 668 |
+
) -> Iterator[str]:
|
| 669 |
+
if not self.access_token:
|
| 670 |
+
raise RuntimeError("access_token is required for image endpoints")
|
| 671 |
+
references = [self._upload_image(image, f"image_{idx}.png") for idx, image in enumerate(images, start=1)]
|
| 672 |
+
self._bootstrap()
|
| 673 |
+
requirements = self._get_chat_requirements()
|
| 674 |
+
conduit_token = self._prepare_image_conversation(prompt, requirements, model)
|
| 675 |
+
response = self._start_image_generation(prompt, requirements, conduit_token, model, references)
|
| 676 |
+
try:
|
| 677 |
+
yield from iter_sse_payloads(response)
|
| 678 |
+
finally:
|
| 679 |
+
response.close()
|
| 680 |
|
| 681 |
def _bootstrap(self) -> None:
|
| 682 |
"""预热首页,并提取 PoW 相关脚本引用。"""
|
|
|
|
| 690 |
if not self.pow_script_sources:
|
| 691 |
self.pow_script_sources = [DEFAULT_POW_SCRIPT]
|
| 692 |
|
| 693 |
+
def _get_chat_requirements(self) -> ChatRequirements:
|
| 694 |
"""获取当前模式对话所需的 sentinel token。"""
|
| 695 |
+
path = "/backend-api/sentinel/chat-requirements" if self.access_token else "/backend-anon/sentinel/chat-requirements"
|
| 696 |
+
context = "auth_chat_requirements" if self.access_token else "noauth_chat_requirements"
|
| 697 |
+
body = {"p": build_legacy_requirements_token(self.user_agent, self.pow_script_sources, self.pow_data_build)}
|
|
|
|
|
|
|
| 698 |
response = self.session.post(
|
| 699 |
self.base_url + path,
|
| 700 |
headers=self._headers(path, {"Content-Type": "application/json"}),
|
|
|
|
| 702 |
timeout=30,
|
| 703 |
)
|
| 704 |
ensure_ok(response, context)
|
| 705 |
+
requirements = self._build_requirements(response.json(), "" if self.access_token else body["p"])
|
| 706 |
if not requirements.token:
|
| 707 |
+
message = "missing auth chat requirements token" if self.access_token else "missing chat requirements token"
|
| 708 |
raise RuntimeError(f"{message}: {requirements.raw_finalize}")
|
| 709 |
return requirements
|
| 710 |
|
| 711 |
+
def _chat_target(self) -> tuple[str, str]:
|
| 712 |
+
if self.access_token:
|
| 713 |
+
return "/backend-api/conversation", "Asia/Shanghai"
|
| 714 |
+
return "/backend-anon/conversation", "America/Los_Angeles"
|
|
|
|
| 715 |
|
| 716 |
+
def list_models(self) -> Dict[str, Any]:
|
| 717 |
+
"""返回当前模式下可用模型,格式对齐 OpenAI `/v1/models`。"""
|
|
|
|
|
|
|
| 718 |
self._bootstrap()
|
| 719 |
+
path = "/backend-api/models?history_and_training_disabled=false" if self.access_token else (
|
| 720 |
"/backend-anon/models?iim=false&is_gizmo=false"
|
| 721 |
)
|
| 722 |
+
route = "/backend-api/models" if self.access_token else "/backend-anon/models"
|
| 723 |
+
context = "auth_models" if self.access_token else "anon_models"
|
| 724 |
response = self.session.get(
|
| 725 |
self.base_url + path,
|
| 726 |
headers=self._headers(route),
|
| 727 |
timeout=30,
|
| 728 |
)
|
| 729 |
ensure_ok(response, context)
|
| 730 |
+
data = []
|
| 731 |
+
seen = set()
|
| 732 |
+
for item in response.json().get("models", []):
|
| 733 |
+
if not isinstance(item, dict):
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 734 |
continue
|
| 735 |
+
slug = str(item.get("slug", "")).strip()
|
| 736 |
+
if not slug or slug in seen:
|
| 737 |
continue
|
| 738 |
+
seen.add(slug)
|
| 739 |
+
data.append({
|
| 740 |
+
"id": slug,
|
| 741 |
+
"object": "model",
|
| 742 |
+
"created": int(item.get("created") or 0),
|
| 743 |
+
"owned_by": str(item.get("owned_by") or "chatgpt"),
|
| 744 |
+
"permission": [],
|
| 745 |
+
"root": slug,
|
| 746 |
+
"parent": None,
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
})
|
| 748 |
+
data.sort(key=lambda item: item["id"])
|
| 749 |
+
return {"object": "list", "data": data}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
services/protocol/__init__.py
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Protocol converters for OpenAI-compatible endpoints."""
|
| 2 |
+
|
services/{anthropic_protocol.py → protocol/anthropic_v1_messages.py}
RENAMED
|
@@ -6,12 +6,26 @@ import re
|
|
| 6 |
import time
|
| 7 |
import uuid
|
| 8 |
from collections.abc import Callable, Iterable, Iterator
|
|
|
|
| 9 |
from typing import Any
|
| 10 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 11 |
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
|
| 12 |
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
|
| 13 |
|
| 14 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
|
| 16 |
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
| 17 |
name = str(tool.get("name") or fn.get("name") or "").strip()
|
|
@@ -93,6 +107,16 @@ def preprocess_payload(payload: dict[str, object], text_mapper: Callable[[str],
|
|
| 93 |
return payload
|
| 94 |
|
| 95 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 96 |
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
|
| 97 |
if not isinstance(messages, list):
|
| 98 |
return messages
|
|
@@ -260,3 +284,23 @@ def _stream_buffered_blocks(content: list[dict[str, object]], start_index: int =
|
|
| 260 |
yield {"type": "content_block_start", "index": index, "content_block": start}
|
| 261 |
yield {"type": "content_block_delta", "index": index, "delta": delta}
|
| 262 |
yield {"type": "content_block_stop", "index": index}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
import time
|
| 7 |
import uuid
|
| 8 |
from collections.abc import Callable, Iterable, Iterator
|
| 9 |
+
from dataclasses import dataclass
|
| 10 |
from typing import Any
|
| 11 |
|
| 12 |
+
from services.account_service import account_service
|
| 13 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 14 |
+
from services.protocol.conversation import count_message_tokens, count_text_tokens, normalize_messages
|
| 15 |
+
from services.protocol.openai_v1_chat_complete import collect_chat_content, stream_text_chat_completion
|
| 16 |
+
|
| 17 |
XML_TOOL_RULE = """Tool output adapter: when calling tools, output ONLY this XML and no prose/markdown:
|
| 18 |
<tool_calls><tool_call><tool_name>TOOL_NAME</tool_name><parameters><PARAM><![CDATA[value]]></PARAM></parameters></tool_call></tool_calls>"""
|
| 19 |
|
| 20 |
|
| 21 |
+
@dataclass
|
| 22 |
+
class MessageRequest:
|
| 23 |
+
backend: OpenAIBackendAPI
|
| 24 |
+
messages: list[dict[str, Any]]
|
| 25 |
+
model: str
|
| 26 |
+
tools: Any = None
|
| 27 |
+
|
| 28 |
+
|
| 29 |
def _tool_meta(tool: dict[str, object]) -> tuple[str, str, object]:
|
| 30 |
fn = tool.get("function") if isinstance(tool.get("function"), dict) else {}
|
| 31 |
name = str(tool.get("name") or fn.get("name") or "").strip()
|
|
|
|
| 107 |
return payload
|
| 108 |
|
| 109 |
|
| 110 |
+
def message_request(body: dict[str, Any]) -> MessageRequest:
|
| 111 |
+
payload = preprocess_payload(dict(body))
|
| 112 |
+
return MessageRequest(
|
| 113 |
+
backend=OpenAIBackendAPI(access_token=account_service.get_text_access_token()),
|
| 114 |
+
messages=normalize_messages(payload.get("messages"), payload.get("system")),
|
| 115 |
+
model=str(payload.get("model") or "auto").strip() or "auto",
|
| 116 |
+
tools=payload.get("tools"),
|
| 117 |
+
)
|
| 118 |
+
|
| 119 |
+
|
| 120 |
def preprocess_messages(messages: object, text_mapper: Callable[[str], str] | None = None) -> object:
|
| 121 |
if not isinstance(messages, list):
|
| 122 |
return messages
|
|
|
|
| 284 |
yield {"type": "content_block_start", "index": index, "content_block": start}
|
| 285 |
yield {"type": "content_block_delta", "index": index, "delta": delta}
|
| 286 |
yield {"type": "content_block_stop", "index": index}
|
| 287 |
+
|
| 288 |
+
|
| 289 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 290 |
+
request = message_request(body)
|
| 291 |
+
if body.get("stream"):
|
| 292 |
+
return stream_events(
|
| 293 |
+
stream_text_chat_completion(request.backend, request.messages, request.model),
|
| 294 |
+
request.model,
|
| 295 |
+
count_message_tokens(request.messages, request.model),
|
| 296 |
+
lambda text: count_text_tokens(text, request.model),
|
| 297 |
+
request.tools,
|
| 298 |
+
)
|
| 299 |
+
text = collect_chat_content(stream_text_chat_completion(request.backend, request.messages, request.model))
|
| 300 |
+
return message_response(
|
| 301 |
+
request.model,
|
| 302 |
+
text,
|
| 303 |
+
count_message_tokens(request.messages, request.model),
|
| 304 |
+
count_text_tokens(text, request.model),
|
| 305 |
+
request.tools,
|
| 306 |
+
)
|
services/protocol/conversation.py
ADDED
|
@@ -0,0 +1,586 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import hashlib
|
| 5 |
+
import json
|
| 6 |
+
import re
|
| 7 |
+
import time
|
| 8 |
+
from dataclasses import dataclass, field
|
| 9 |
+
from pathlib import Path
|
| 10 |
+
from typing import Any, Iterable, Iterator
|
| 11 |
+
|
| 12 |
+
import tiktoken
|
| 13 |
+
|
| 14 |
+
from services.account_service import account_service
|
| 15 |
+
from services.config import config
|
| 16 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 17 |
+
from utils.helper import IMAGE_MODELS
|
| 18 |
+
from utils.log import logger
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
class ImageGenerationError(Exception):
|
| 22 |
+
def __init__(
|
| 23 |
+
self,
|
| 24 |
+
message: str,
|
| 25 |
+
status_code: int = 502,
|
| 26 |
+
error_type: str = "server_error",
|
| 27 |
+
code: str | None = "upstream_error",
|
| 28 |
+
param: str | None = None,
|
| 29 |
+
) -> None:
|
| 30 |
+
super().__init__(message)
|
| 31 |
+
self.status_code = status_code
|
| 32 |
+
self.error_type = error_type
|
| 33 |
+
self.code = code
|
| 34 |
+
self.param = param
|
| 35 |
+
|
| 36 |
+
def to_openai_error(self) -> dict[str, Any]:
|
| 37 |
+
return {
|
| 38 |
+
"error": {
|
| 39 |
+
"message": str(self),
|
| 40 |
+
"type": self.error_type,
|
| 41 |
+
"param": self.param,
|
| 42 |
+
"code": self.code,
|
| 43 |
+
}
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
|
| 47 |
+
def is_token_invalid_error(message: str) -> bool:
|
| 48 |
+
text = str(message or "").lower()
|
| 49 |
+
return (
|
| 50 |
+
"token_invalidated" in text
|
| 51 |
+
or "token_revoked" in text
|
| 52 |
+
or "authentication token has been invalidated" in text
|
| 53 |
+
or "invalidated oauth token" in text
|
| 54 |
+
)
|
| 55 |
+
|
| 56 |
+
|
| 57 |
+
def encode_images(images: Iterable[tuple[bytes, str, str]]) -> list[str]:
|
| 58 |
+
return [base64.b64encode(data).decode("ascii") for data, _, _ in images if data]
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def save_image_bytes(image_data: bytes, base_url: str | None = None) -> str:
|
| 62 |
+
config.cleanup_old_images()
|
| 63 |
+
file_hash = hashlib.md5(image_data).hexdigest()
|
| 64 |
+
filename = f"{int(time.time())}_{file_hash}.png"
|
| 65 |
+
relative_dir = Path(time.strftime("%Y"), time.strftime("%m"), time.strftime("%d"))
|
| 66 |
+
file_path = config.images_dir / relative_dir / filename
|
| 67 |
+
file_path.parent.mkdir(parents=True, exist_ok=True)
|
| 68 |
+
file_path.write_bytes(image_data)
|
| 69 |
+
return f"{(base_url or config.base_url)}/images/{relative_dir.as_posix()}/{filename}"
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
def message_text(content: Any) -> str:
|
| 73 |
+
if isinstance(content, str):
|
| 74 |
+
return content
|
| 75 |
+
if isinstance(content, list):
|
| 76 |
+
parts = []
|
| 77 |
+
for item in content:
|
| 78 |
+
if isinstance(item, str):
|
| 79 |
+
parts.append(item)
|
| 80 |
+
elif isinstance(item, dict) and str(item.get("type") or "") in {"text", "input_text", "output_text"}:
|
| 81 |
+
parts.append(str(item.get("text") or ""))
|
| 82 |
+
return "".join(parts)
|
| 83 |
+
return ""
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
def normalize_messages(messages: object, system: Any = None) -> list[dict[str, Any]]:
|
| 87 |
+
normalized = []
|
| 88 |
+
system_text = message_text(system)
|
| 89 |
+
if system_text:
|
| 90 |
+
normalized.append({"role": "system", "content": system_text})
|
| 91 |
+
if isinstance(messages, list):
|
| 92 |
+
for message in messages:
|
| 93 |
+
if isinstance(message, dict):
|
| 94 |
+
normalized.append({"role": message.get("role", "user"), "content": message_text(message.get("content", ""))})
|
| 95 |
+
return normalized
|
| 96 |
+
|
| 97 |
+
|
| 98 |
+
def assistant_history_text(messages: list[dict[str, Any]]) -> str:
|
| 99 |
+
return "".join(str(item.get("content") or "") for item in messages if item.get("role") == "assistant")
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def build_image_prompt(prompt: str, size: str | None) -> str:
|
| 103 |
+
if not size:
|
| 104 |
+
return prompt
|
| 105 |
+
if size not in {"1:1", "16:9", "9:16", "4:3", "3:4"}:
|
| 106 |
+
return f"{prompt.strip()}\n\n输出图片,宽高比为 {size}。"
|
| 107 |
+
hint = {
|
| 108 |
+
"1:1": "输出为 1:1 正方形构图,主体居中,适合正方形画幅。",
|
| 109 |
+
"16:9": "输出为 16:9 横屏构图,适合宽画幅展示。",
|
| 110 |
+
"9:16": "输出为 9:16 竖屏构图,适合竖版画幅展示。",
|
| 111 |
+
"4:3": "输出为 4:3 比例,兼顾宽度与高度,适合展示画面细节。",
|
| 112 |
+
"3:4": "输出为 3:4 比例,纵向构图,适合人物肖像或竖向场景。",
|
| 113 |
+
}[size]
|
| 114 |
+
return f"{prompt.strip()}\n\n{hint}"
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def encoding_for_model(model: str):
|
| 118 |
+
try:
|
| 119 |
+
return tiktoken.encoding_for_model(model)
|
| 120 |
+
except KeyError:
|
| 121 |
+
try:
|
| 122 |
+
return tiktoken.get_encoding("o200k_base")
|
| 123 |
+
except KeyError:
|
| 124 |
+
return tiktoken.get_encoding("cl100k_base")
|
| 125 |
+
|
| 126 |
+
|
| 127 |
+
def count_message_tokens(messages: list[dict[str, Any]], model: str) -> int:
|
| 128 |
+
encoding = encoding_for_model(model)
|
| 129 |
+
total = 0
|
| 130 |
+
for message in messages:
|
| 131 |
+
total += 3
|
| 132 |
+
for key, value in message.items():
|
| 133 |
+
if not isinstance(value, str):
|
| 134 |
+
continue
|
| 135 |
+
total += len(encoding.encode(value))
|
| 136 |
+
if key == "name":
|
| 137 |
+
total += 1
|
| 138 |
+
return total + 3
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def count_text_tokens(text: str, model: str) -> int:
|
| 142 |
+
return len(encoding_for_model(model).encode(text))
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
def format_image_result(
|
| 146 |
+
items: list[dict[str, Any]],
|
| 147 |
+
prompt: str,
|
| 148 |
+
response_format: str,
|
| 149 |
+
base_url: str | None = None,
|
| 150 |
+
created: int | None = None,
|
| 151 |
+
message: str = "",
|
| 152 |
+
) -> dict[str, Any]:
|
| 153 |
+
data: list[dict[str, Any]] = []
|
| 154 |
+
for item in items:
|
| 155 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 156 |
+
if not b64_json:
|
| 157 |
+
continue
|
| 158 |
+
revised_prompt = str(item.get("revised_prompt") or prompt).strip() or prompt
|
| 159 |
+
if response_format == "b64_json":
|
| 160 |
+
data.append({
|
| 161 |
+
"b64_json": b64_json,
|
| 162 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 163 |
+
"revised_prompt": revised_prompt,
|
| 164 |
+
})
|
| 165 |
+
else:
|
| 166 |
+
data.append({
|
| 167 |
+
"url": save_image_bytes(base64.b64decode(b64_json), base_url),
|
| 168 |
+
"revised_prompt": revised_prompt,
|
| 169 |
+
})
|
| 170 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 171 |
+
if message and not data:
|
| 172 |
+
result["message"] = message
|
| 173 |
+
return result
|
| 174 |
+
|
| 175 |
+
|
| 176 |
+
@dataclass
|
| 177 |
+
class ConversationRequest:
|
| 178 |
+
model: str = "auto"
|
| 179 |
+
prompt: str = ""
|
| 180 |
+
messages: list[dict[str, Any]] | None = None
|
| 181 |
+
images: list[str] | None = None
|
| 182 |
+
n: int = 1
|
| 183 |
+
size: str | None = None
|
| 184 |
+
response_format: str = "b64_json"
|
| 185 |
+
base_url: str | None = None
|
| 186 |
+
message_as_error: bool = False
|
| 187 |
+
|
| 188 |
+
|
| 189 |
+
@dataclass
|
| 190 |
+
class ConversationState:
|
| 191 |
+
text: str = ""
|
| 192 |
+
conversation_id: str = ""
|
| 193 |
+
file_ids: list[str] = field(default_factory=list)
|
| 194 |
+
sediment_ids: list[str] = field(default_factory=list)
|
| 195 |
+
blocked: bool = False
|
| 196 |
+
tool_invoked: bool | None = None
|
| 197 |
+
turn_use_case: str = ""
|
| 198 |
+
|
| 199 |
+
|
| 200 |
+
@dataclass
|
| 201 |
+
class ImageOutput:
|
| 202 |
+
kind: str
|
| 203 |
+
model: str
|
| 204 |
+
index: int
|
| 205 |
+
total: int
|
| 206 |
+
created: int = field(default_factory=lambda: int(time.time()))
|
| 207 |
+
text: str = ""
|
| 208 |
+
upstream_event_type: str = ""
|
| 209 |
+
data: list[dict[str, Any]] = field(default_factory=list)
|
| 210 |
+
|
| 211 |
+
def to_chunk(self) -> dict[str, Any]:
|
| 212 |
+
chunk: dict[str, Any] = {
|
| 213 |
+
"object": "image.generation.chunk",
|
| 214 |
+
"created": self.created,
|
| 215 |
+
"model": self.model,
|
| 216 |
+
"index": self.index,
|
| 217 |
+
"total": self.total,
|
| 218 |
+
"progress_text": self.text,
|
| 219 |
+
"upstream_event_type": self.upstream_event_type,
|
| 220 |
+
"data": [],
|
| 221 |
+
}
|
| 222 |
+
if self.kind == "message":
|
| 223 |
+
chunk.update({
|
| 224 |
+
"object": "image.generation.message",
|
| 225 |
+
"message": self.text,
|
| 226 |
+
})
|
| 227 |
+
chunk.pop("progress_text", None)
|
| 228 |
+
chunk.pop("upstream_event_type", None)
|
| 229 |
+
elif self.kind == "result":
|
| 230 |
+
chunk.update({
|
| 231 |
+
"object": "image.generation.result",
|
| 232 |
+
"data": self.data,
|
| 233 |
+
})
|
| 234 |
+
chunk.pop("progress_text", None)
|
| 235 |
+
chunk.pop("upstream_event_type", None)
|
| 236 |
+
return chunk
|
| 237 |
+
|
| 238 |
+
|
| 239 |
+
def assistant_message_text(message: dict[str, Any]) -> str:
|
| 240 |
+
content = message.get("content") or {}
|
| 241 |
+
parts = content.get("parts") or []
|
| 242 |
+
if not isinstance(parts, list):
|
| 243 |
+
return ""
|
| 244 |
+
return "".join(part for part in parts if isinstance(part, str))
|
| 245 |
+
|
| 246 |
+
|
| 247 |
+
def strip_history(text: str, history_text: str = "") -> str:
|
| 248 |
+
text = str(text or "")
|
| 249 |
+
history_text = str(history_text or "")
|
| 250 |
+
return text[len(history_text):] if history_text and text.startswith(history_text) else text
|
| 251 |
+
|
| 252 |
+
|
| 253 |
+
def assistant_text(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 254 |
+
for candidate in (event, event.get("v")):
|
| 255 |
+
if not isinstance(candidate, dict):
|
| 256 |
+
continue
|
| 257 |
+
message = candidate.get("message")
|
| 258 |
+
if not isinstance(message, dict):
|
| 259 |
+
continue
|
| 260 |
+
role = str((message.get("author") or {}).get("role") or "").strip().lower()
|
| 261 |
+
if role != "assistant":
|
| 262 |
+
continue
|
| 263 |
+
text = assistant_message_text(message)
|
| 264 |
+
if text:
|
| 265 |
+
return strip_history(text, history_text)
|
| 266 |
+
return apply_text_patch(event, current_text, history_text)
|
| 267 |
+
|
| 268 |
+
|
| 269 |
+
def apply_text_patch(event: dict[str, Any], current_text: str = "", history_text: str = "") -> str:
|
| 270 |
+
if event.get("p") == "/message/content/parts/0":
|
| 271 |
+
return apply_patch_op(event, current_text, history_text)
|
| 272 |
+
|
| 273 |
+
operations = event.get("v")
|
| 274 |
+
if isinstance(operations, str) and current_text and not event.get("p") and not event.get("o"):
|
| 275 |
+
return current_text + operations
|
| 276 |
+
|
| 277 |
+
if event.get("o") == "patch" and isinstance(operations, list):
|
| 278 |
+
text = current_text
|
| 279 |
+
for item in operations:
|
| 280 |
+
if isinstance(item, dict):
|
| 281 |
+
text = apply_text_patch(item, text, history_text)
|
| 282 |
+
return text
|
| 283 |
+
|
| 284 |
+
if not isinstance(operations, list):
|
| 285 |
+
return current_text
|
| 286 |
+
|
| 287 |
+
text = current_text
|
| 288 |
+
for item in operations:
|
| 289 |
+
if isinstance(item, dict):
|
| 290 |
+
text = apply_text_patch(item, text, history_text)
|
| 291 |
+
return text
|
| 292 |
+
|
| 293 |
+
|
| 294 |
+
def apply_patch_op(operation: dict[str, Any], current_text: str, history_text: str = "") -> str:
|
| 295 |
+
op = operation.get("o")
|
| 296 |
+
value = str(operation.get("v") or "")
|
| 297 |
+
if op == "append":
|
| 298 |
+
return current_text + value
|
| 299 |
+
if op == "replace":
|
| 300 |
+
return strip_history(value, history_text)
|
| 301 |
+
return current_text
|
| 302 |
+
|
| 303 |
+
|
| 304 |
+
def add_unique(values: list[str], candidates: list[str]) -> None:
|
| 305 |
+
for candidate in candidates:
|
| 306 |
+
if candidate and candidate not in values:
|
| 307 |
+
values.append(candidate)
|
| 308 |
+
|
| 309 |
+
|
| 310 |
+
def extract_conversation_ids(payload: str) -> tuple[str, list[str], list[str]]:
|
| 311 |
+
conversation_match = re.search(r'"conversation_id"\s*:\s*"([^"]+)"', payload)
|
| 312 |
+
conversation_id = conversation_match.group(1) if conversation_match else ""
|
| 313 |
+
file_ids = re.findall(r"(file[-_][A-Za-z0-9]+)", payload)
|
| 314 |
+
sediment_ids = re.findall(r"sediment://([A-Za-z0-9_-]+)", payload)
|
| 315 |
+
return conversation_id, file_ids, sediment_ids
|
| 316 |
+
|
| 317 |
+
|
| 318 |
+
def is_image_tool_event(event: dict[str, Any]) -> bool:
|
| 319 |
+
value = event.get("v")
|
| 320 |
+
message = event.get("message") or (value.get("message") if isinstance(value, dict) else None)
|
| 321 |
+
if not isinstance(message, dict):
|
| 322 |
+
return False
|
| 323 |
+
metadata = message.get("metadata") or {}
|
| 324 |
+
author = message.get("author") or {}
|
| 325 |
+
return author.get("role") == "tool" and metadata.get("async_task_type") == "image_gen"
|
| 326 |
+
|
| 327 |
+
|
| 328 |
+
def update_conversation_state(state: ConversationState, payload: str, event: dict[str, Any] | None = None) -> None:
|
| 329 |
+
conversation_id, file_ids, sediment_ids = extract_conversation_ids(payload)
|
| 330 |
+
if conversation_id and not state.conversation_id:
|
| 331 |
+
state.conversation_id = conversation_id
|
| 332 |
+
if isinstance(event, dict) and is_image_tool_event(event):
|
| 333 |
+
add_unique(state.file_ids, file_ids)
|
| 334 |
+
add_unique(state.sediment_ids, sediment_ids)
|
| 335 |
+
if not isinstance(event, dict):
|
| 336 |
+
return
|
| 337 |
+
state.conversation_id = str(event.get("conversation_id") or state.conversation_id)
|
| 338 |
+
value = event.get("v")
|
| 339 |
+
if isinstance(value, dict):
|
| 340 |
+
state.conversation_id = str(value.get("conversation_id") or state.conversation_id)
|
| 341 |
+
if event.get("type") == "moderation":
|
| 342 |
+
moderation = event.get("moderation_response")
|
| 343 |
+
if isinstance(moderation, dict) and moderation.get("blocked") is True:
|
| 344 |
+
state.blocked = True
|
| 345 |
+
if event.get("type") == "server_ste_metadata":
|
| 346 |
+
metadata = event.get("metadata")
|
| 347 |
+
if isinstance(metadata, dict):
|
| 348 |
+
if isinstance(metadata.get("tool_invoked"), bool):
|
| 349 |
+
state.tool_invoked = metadata["tool_invoked"]
|
| 350 |
+
state.turn_use_case = str(metadata.get("turn_use_case") or state.turn_use_case)
|
| 351 |
+
|
| 352 |
+
|
| 353 |
+
def conversation_base_event(event_type: str, state: ConversationState, **extra: Any) -> dict[str, Any]:
|
| 354 |
+
return {
|
| 355 |
+
"type": event_type,
|
| 356 |
+
"text": state.text,
|
| 357 |
+
"conversation_id": state.conversation_id,
|
| 358 |
+
"file_ids": list(state.file_ids),
|
| 359 |
+
"sediment_ids": list(state.sediment_ids),
|
| 360 |
+
"blocked": state.blocked,
|
| 361 |
+
"tool_invoked": state.tool_invoked,
|
| 362 |
+
"turn_use_case": state.turn_use_case,
|
| 363 |
+
**extra,
|
| 364 |
+
}
|
| 365 |
+
|
| 366 |
+
|
| 367 |
+
def iter_conversation_payloads(payloads: Iterator[str], history_text: str = "") -> Iterator[dict[str, Any]]:
|
| 368 |
+
state = ConversationState()
|
| 369 |
+
for payload in payloads:
|
| 370 |
+
# print(f"[upstream_sse] {payload}", flush=True)
|
| 371 |
+
if not payload:
|
| 372 |
+
continue
|
| 373 |
+
if payload == "[DONE]":
|
| 374 |
+
yield conversation_base_event("conversation.done", state, done=True)
|
| 375 |
+
break
|
| 376 |
+
try:
|
| 377 |
+
event = json.loads(payload)
|
| 378 |
+
except json.JSONDecodeError:
|
| 379 |
+
update_conversation_state(state, payload)
|
| 380 |
+
yield conversation_base_event("conversation.raw", state, payload=payload)
|
| 381 |
+
continue
|
| 382 |
+
if not isinstance(event, dict):
|
| 383 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 384 |
+
continue
|
| 385 |
+
update_conversation_state(state, payload, event)
|
| 386 |
+
next_text = assistant_text(event, state.text, history_text)
|
| 387 |
+
if next_text != state.text:
|
| 388 |
+
delta = next_text[len(state.text):] if next_text.startswith(state.text) else next_text
|
| 389 |
+
state.text = next_text
|
| 390 |
+
yield conversation_base_event("conversation.delta", state, raw=event, delta=delta)
|
| 391 |
+
continue
|
| 392 |
+
yield conversation_base_event("conversation.event", state, raw=event)
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
def conversation_events(
|
| 396 |
+
backend: OpenAIBackendAPI,
|
| 397 |
+
messages: list[dict[str, Any]] | None = None,
|
| 398 |
+
model: str = "auto",
|
| 399 |
+
prompt: str = "",
|
| 400 |
+
images: list[str] | None = None,
|
| 401 |
+
size: str | None = None,
|
| 402 |
+
) -> Iterator[dict[str, Any]]:
|
| 403 |
+
normalized = normalize_messages(messages or ([{"role": "user", "content": prompt}] if prompt else []))
|
| 404 |
+
image_model = str(model or "").strip() in IMAGE_MODELS
|
| 405 |
+
history_text = "" if image_model else assistant_history_text(normalized)
|
| 406 |
+
final_prompt = build_image_prompt(prompt, size) if image_model else prompt
|
| 407 |
+
payloads = backend.stream_conversation(
|
| 408 |
+
messages=normalized,
|
| 409 |
+
model=model,
|
| 410 |
+
prompt=final_prompt,
|
| 411 |
+
images=images if image_model else None,
|
| 412 |
+
system_hints=["picture_v2"] if image_model else None,
|
| 413 |
+
)
|
| 414 |
+
yield from iter_conversation_payloads(payloads, history_text)
|
| 415 |
+
|
| 416 |
+
|
| 417 |
+
def text_backend() -> OpenAIBackendAPI:
|
| 418 |
+
return OpenAIBackendAPI(access_token=account_service.get_text_access_token())
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
def stream_text_deltas(backend: OpenAIBackendAPI, request: ConversationRequest) -> Iterator[str]:
|
| 422 |
+
for event in conversation_events(backend, messages=request.messages, model=request.model, prompt=request.prompt):
|
| 423 |
+
if event.get("type") != "conversation.delta":
|
| 424 |
+
continue
|
| 425 |
+
delta = str(event.get("delta") or "")
|
| 426 |
+
if delta:
|
| 427 |
+
yield delta
|
| 428 |
+
|
| 429 |
+
|
| 430 |
+
def collect_text(backend: OpenAIBackendAPI, request: ConversationRequest) -> str:
|
| 431 |
+
return "".join(stream_text_deltas(backend, request))
|
| 432 |
+
|
| 433 |
+
|
| 434 |
+
def stream_image_outputs(
|
| 435 |
+
backend: OpenAIBackendAPI,
|
| 436 |
+
request: ConversationRequest,
|
| 437 |
+
index: int = 1,
|
| 438 |
+
total: int = 1,
|
| 439 |
+
) -> Iterator[ImageOutput]:
|
| 440 |
+
last: dict[str, Any] = {}
|
| 441 |
+
for event in conversation_events(
|
| 442 |
+
backend,
|
| 443 |
+
prompt=request.prompt,
|
| 444 |
+
model=request.model,
|
| 445 |
+
images=request.images or [],
|
| 446 |
+
size=request.size,
|
| 447 |
+
):
|
| 448 |
+
last = event
|
| 449 |
+
if event.get("type") == "conversation.delta":
|
| 450 |
+
yield ImageOutput(
|
| 451 |
+
kind="progress",
|
| 452 |
+
model=request.model,
|
| 453 |
+
index=index,
|
| 454 |
+
total=total,
|
| 455 |
+
text=str(event.get("delta") or ""),
|
| 456 |
+
upstream_event_type="conversation.delta",
|
| 457 |
+
)
|
| 458 |
+
continue
|
| 459 |
+
if event.get("type") == "conversation.event":
|
| 460 |
+
raw = event.get("raw")
|
| 461 |
+
raw_type = str(raw.get("type") or "") if isinstance(raw, dict) else ""
|
| 462 |
+
yield ImageOutput(
|
| 463 |
+
kind="progress",
|
| 464 |
+
model=request.model,
|
| 465 |
+
index=index,
|
| 466 |
+
total=total,
|
| 467 |
+
upstream_event_type=raw_type,
|
| 468 |
+
)
|
| 469 |
+
|
| 470 |
+
conversation_id = str(last.get("conversation_id") or "")
|
| 471 |
+
file_ids = [str(item) for item in last.get("file_ids") or []]
|
| 472 |
+
sediment_ids = [str(item) for item in last.get("sediment_ids") or []]
|
| 473 |
+
message = str(last.get("text") or "").strip()
|
| 474 |
+
is_text_response = last.get("tool_invoked") is False or last.get("turn_use_case") == "text"
|
| 475 |
+
logger.info({
|
| 476 |
+
"event": "image_stream_resolve_start",
|
| 477 |
+
"conversation_id": conversation_id,
|
| 478 |
+
"file_ids": file_ids,
|
| 479 |
+
"sediment_ids": sediment_ids,
|
| 480 |
+
"tool_invoked": last.get("tool_invoked"),
|
| 481 |
+
"turn_use_case": last.get("turn_use_case"),
|
| 482 |
+
})
|
| 483 |
+
if message and not file_ids and not sediment_ids and (last.get("blocked") or is_text_response):
|
| 484 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 485 |
+
return
|
| 486 |
+
|
| 487 |
+
image_urls = backend.resolve_conversation_image_urls(conversation_id, file_ids, sediment_ids)
|
| 488 |
+
if image_urls:
|
| 489 |
+
image_items = [
|
| 490 |
+
{"b64_json": base64.b64encode(image_data).decode("ascii")}
|
| 491 |
+
for image_data in backend.download_image_bytes(image_urls)
|
| 492 |
+
]
|
| 493 |
+
data = format_image_result(
|
| 494 |
+
image_items,
|
| 495 |
+
request.prompt,
|
| 496 |
+
request.response_format,
|
| 497 |
+
request.base_url,
|
| 498 |
+
int(time.time()),
|
| 499 |
+
)["data"]
|
| 500 |
+
if data:
|
| 501 |
+
yield ImageOutput(kind="result", model=request.model, index=index, total=total, data=data)
|
| 502 |
+
return
|
| 503 |
+
|
| 504 |
+
if message:
|
| 505 |
+
yield ImageOutput(kind="message", model=request.model, index=index, total=total, text=message)
|
| 506 |
+
|
| 507 |
+
|
| 508 |
+
def stream_image_outputs_with_pool(request: ConversationRequest) -> Iterator[ImageOutput]:
|
| 509 |
+
if str(request.model or "").strip() not in IMAGE_MODELS:
|
| 510 |
+
raise ImageGenerationError("unsupported image model,supported models: " + ", ".join(IMAGE_MODELS))
|
| 511 |
+
|
| 512 |
+
emitted = False
|
| 513 |
+
last_error = ""
|
| 514 |
+
for index in range(1, request.n + 1):
|
| 515 |
+
while True:
|
| 516 |
+
try:
|
| 517 |
+
token = account_service.get_available_access_token()
|
| 518 |
+
except RuntimeError as exc:
|
| 519 |
+
if emitted:
|
| 520 |
+
return
|
| 521 |
+
raise ImageGenerationError(str(exc) or "image generation failed") from exc
|
| 522 |
+
|
| 523 |
+
emitted_for_token = False
|
| 524 |
+
returned_message = False
|
| 525 |
+
returned_result = False
|
| 526 |
+
try:
|
| 527 |
+
backend = OpenAIBackendAPI(access_token=token)
|
| 528 |
+
for output in stream_image_outputs(backend, request, index, request.n):
|
| 529 |
+
if output.kind == "message" and request.message_as_error:
|
| 530 |
+
raise ImageGenerationError(
|
| 531 |
+
output.text or "Image generation was rejected by upstream policy.",
|
| 532 |
+
status_code=400,
|
| 533 |
+
error_type="invalid_request_error",
|
| 534 |
+
code="content_policy_violation",
|
| 535 |
+
)
|
| 536 |
+
emitted = True
|
| 537 |
+
emitted_for_token = True
|
| 538 |
+
returned_message = output.kind == "message"
|
| 539 |
+
returned_result = returned_result or output.kind == "result"
|
| 540 |
+
yield output
|
| 541 |
+
if returned_message or not returned_result:
|
| 542 |
+
account_service.mark_image_result(token, False)
|
| 543 |
+
return
|
| 544 |
+
account_service.mark_image_result(token, True)
|
| 545 |
+
break
|
| 546 |
+
except ImageGenerationError:
|
| 547 |
+
account_service.mark_image_result(token, False)
|
| 548 |
+
raise
|
| 549 |
+
except Exception as exc:
|
| 550 |
+
account_service.mark_image_result(token, False)
|
| 551 |
+
last_error = str(exc)
|
| 552 |
+
logger.warning({"event": "image_stream_fail", "request_token": token, "error": last_error})
|
| 553 |
+
if not emitted_for_token and is_token_invalid_error(last_error):
|
| 554 |
+
account_service.remove_invalid_token(token, "image_stream")
|
| 555 |
+
continue
|
| 556 |
+
raise ImageGenerationError(last_error or "image generation failed") from exc
|
| 557 |
+
|
| 558 |
+
if not emitted:
|
| 559 |
+
raise ImageGenerationError(last_error or "image generation failed")
|
| 560 |
+
|
| 561 |
+
|
| 562 |
+
def stream_image_chunks(outputs: Iterable[ImageOutput]) -> Iterator[dict[str, Any]]:
|
| 563 |
+
for output in outputs:
|
| 564 |
+
yield output.to_chunk()
|
| 565 |
+
|
| 566 |
+
|
| 567 |
+
def collect_image_outputs(outputs: Iterable[ImageOutput]) -> dict[str, Any]:
|
| 568 |
+
created = None
|
| 569 |
+
data: list[dict[str, Any]] = []
|
| 570 |
+
message = ""
|
| 571 |
+
progress_parts: list[str] = []
|
| 572 |
+
for output in outputs:
|
| 573 |
+
created = created or output.created
|
| 574 |
+
if output.kind == "progress" and output.text:
|
| 575 |
+
progress_parts.append(output.text)
|
| 576 |
+
elif output.kind == "message":
|
| 577 |
+
message = output.text
|
| 578 |
+
elif output.kind == "result":
|
| 579 |
+
data.extend(output.data)
|
| 580 |
+
|
| 581 |
+
result: dict[str, Any] = {"created": created or int(time.time()), "data": data}
|
| 582 |
+
if not data:
|
| 583 |
+
text = message or "".join(progress_parts).strip()
|
| 584 |
+
if text:
|
| 585 |
+
result["message"] = text
|
| 586 |
+
return result
|
services/protocol/openai_v1_chat_complete.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import time
|
| 4 |
+
import uuid
|
| 5 |
+
from typing import Any, Iterable, Iterator
|
| 6 |
+
|
| 7 |
+
from fastapi import HTTPException
|
| 8 |
+
|
| 9 |
+
from services.protocol.conversation import (
|
| 10 |
+
ConversationRequest,
|
| 11 |
+
ImageOutput,
|
| 12 |
+
collect_image_outputs,
|
| 13 |
+
collect_text,
|
| 14 |
+
count_message_tokens,
|
| 15 |
+
count_text_tokens,
|
| 16 |
+
encode_images,
|
| 17 |
+
normalize_messages,
|
| 18 |
+
stream_image_outputs_with_pool,
|
| 19 |
+
stream_text_deltas,
|
| 20 |
+
text_backend,
|
| 21 |
+
)
|
| 22 |
+
from utils.helper import build_chat_image_markdown_content, extract_chat_image, extract_chat_prompt, is_image_chat_request, parse_image_count
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def completion_chunk(model: str, delta: dict[str, Any], finish_reason: str | None = None, completion_id: str = "", created: int | None = None) -> dict[str, Any]:
|
| 26 |
+
return {
|
| 27 |
+
"id": completion_id or f"chatcmpl-{uuid.uuid4().hex}",
|
| 28 |
+
"object": "chat.completion.chunk",
|
| 29 |
+
"created": created or int(time.time()),
|
| 30 |
+
"model": model,
|
| 31 |
+
"choices": [{"index": 0, "delta": delta, "finish_reason": finish_reason}],
|
| 32 |
+
}
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
def completion_response(
|
| 36 |
+
model: str,
|
| 37 |
+
content: str,
|
| 38 |
+
created: int | None = None,
|
| 39 |
+
messages: list[dict[str, Any]] | None = None,
|
| 40 |
+
) -> dict[str, Any]:
|
| 41 |
+
prompt_tokens = count_message_tokens(messages, model) if messages else 0
|
| 42 |
+
completion_tokens = count_text_tokens(content, model) if messages else 0
|
| 43 |
+
return {
|
| 44 |
+
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 45 |
+
"object": "chat.completion",
|
| 46 |
+
"created": created or int(time.time()),
|
| 47 |
+
"model": model,
|
| 48 |
+
"choices": [{
|
| 49 |
+
"index": 0,
|
| 50 |
+
"message": {"role": "assistant", "content": content},
|
| 51 |
+
"finish_reason": "stop",
|
| 52 |
+
}],
|
| 53 |
+
"usage": {
|
| 54 |
+
"prompt_tokens": prompt_tokens,
|
| 55 |
+
"completion_tokens": completion_tokens,
|
| 56 |
+
"total_tokens": prompt_tokens + completion_tokens,
|
| 57 |
+
},
|
| 58 |
+
}
|
| 59 |
+
|
| 60 |
+
|
| 61 |
+
def stream_text_chat_completion(backend, messages: list[dict[str, Any]], model: str) -> Iterator[dict[str, Any]]:
|
| 62 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 63 |
+
created = int(time.time())
|
| 64 |
+
sent_role = False
|
| 65 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 66 |
+
for delta_text in stream_text_deltas(backend, request):
|
| 67 |
+
if not sent_role:
|
| 68 |
+
sent_role = True
|
| 69 |
+
yield completion_chunk(model, {"role": "assistant", "content": delta_text}, None, completion_id, created)
|
| 70 |
+
else:
|
| 71 |
+
yield completion_chunk(model, {"content": delta_text}, None, completion_id, created)
|
| 72 |
+
if not sent_role:
|
| 73 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 74 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
def collect_chat_content(chunks: Iterable[dict[str, Any]]) -> str:
|
| 78 |
+
parts: list[str] = []
|
| 79 |
+
for chunk in chunks:
|
| 80 |
+
choices = chunk.get("choices")
|
| 81 |
+
first = choices[0] if isinstance(choices, list) and choices and isinstance(choices[0], dict) else {}
|
| 82 |
+
delta = first.get("delta") if isinstance(first.get("delta"), dict) else {}
|
| 83 |
+
content = str(delta.get("content") or "")
|
| 84 |
+
if content:
|
| 85 |
+
parts.append(content)
|
| 86 |
+
return "".join(parts)
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
def chat_messages_from_body(body: dict[str, Any]) -> list[dict[str, Any]]:
|
| 90 |
+
messages = body.get("messages")
|
| 91 |
+
if isinstance(messages, list) and messages:
|
| 92 |
+
return [message for message in messages if isinstance(message, dict)]
|
| 93 |
+
prompt = str(body.get("prompt") or "").strip()
|
| 94 |
+
if prompt:
|
| 95 |
+
return [{"role": "user", "content": prompt}]
|
| 96 |
+
raise HTTPException(status_code=400, detail={"error": "messages or prompt is required"})
|
| 97 |
+
|
| 98 |
+
|
| 99 |
+
def chat_image_args(body: dict[str, Any]) -> tuple[str, str, int, list[tuple[bytes, str, str]]]:
|
| 100 |
+
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 101 |
+
prompt = extract_chat_prompt(body)
|
| 102 |
+
if not prompt:
|
| 103 |
+
raise HTTPException(status_code=400, detail={"error": "prompt is required"})
|
| 104 |
+
images = [
|
| 105 |
+
(data, f"image_{idx}.png", mime)
|
| 106 |
+
for idx, (data, mime) in enumerate(extract_chat_image(body), start=1)
|
| 107 |
+
]
|
| 108 |
+
return model, prompt, parse_image_count(body.get("n")), images
|
| 109 |
+
|
| 110 |
+
|
| 111 |
+
def text_chat_parts(body: dict[str, Any]) -> tuple[str, list[dict[str, Any]]]:
|
| 112 |
+
model = str(body.get("model") or "auto").strip() or "auto"
|
| 113 |
+
messages = normalize_messages(chat_messages_from_body(body))
|
| 114 |
+
return model, messages
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def image_result_content(result: dict[str, Any]) -> str:
|
| 118 |
+
data = result.get("data")
|
| 119 |
+
if isinstance(data, list) and data:
|
| 120 |
+
return build_chat_image_markdown_content(result)
|
| 121 |
+
return str(result.get("message") or "Image generation completed.")
|
| 122 |
+
|
| 123 |
+
|
| 124 |
+
def image_chat_response(body: dict[str, Any]) -> dict[str, Any]:
|
| 125 |
+
model, prompt, n, images = chat_image_args(body)
|
| 126 |
+
result = collect_image_outputs(stream_image_outputs_with_pool(ConversationRequest(
|
| 127 |
+
prompt=prompt,
|
| 128 |
+
model=model,
|
| 129 |
+
n=n,
|
| 130 |
+
response_format="b64_json",
|
| 131 |
+
images=encode_images(images) or None,
|
| 132 |
+
)))
|
| 133 |
+
return completion_response(model, image_result_content(result), int(result.get("created") or 0) or None)
|
| 134 |
+
|
| 135 |
+
|
| 136 |
+
def image_chat_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 137 |
+
model, prompt, n, images = chat_image_args(body)
|
| 138 |
+
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 139 |
+
prompt=prompt,
|
| 140 |
+
model=model,
|
| 141 |
+
n=n,
|
| 142 |
+
response_format="b64_json",
|
| 143 |
+
images=encode_images(images) or None,
|
| 144 |
+
))
|
| 145 |
+
yield from stream_image_chat_completion(image_outputs, model)
|
| 146 |
+
|
| 147 |
+
|
| 148 |
+
def stream_image_chat_completion(image_outputs: Iterable[ImageOutput], model: str) -> Iterator[dict[str, Any]]:
|
| 149 |
+
completion_id = f"chatcmpl-{uuid.uuid4().hex}"
|
| 150 |
+
created = int(time.time())
|
| 151 |
+
sent_role = False
|
| 152 |
+
sent_text = ""
|
| 153 |
+
for output in image_outputs:
|
| 154 |
+
content = ""
|
| 155 |
+
if output.kind == "progress":
|
| 156 |
+
content = output.text
|
| 157 |
+
sent_text += content
|
| 158 |
+
elif output.kind == "result":
|
| 159 |
+
content = build_chat_image_markdown_content({"data": output.data})
|
| 160 |
+
elif output.kind == "message":
|
| 161 |
+
content = output.text[len(sent_text):] if output.text.startswith(sent_text) else output.text
|
| 162 |
+
if not content:
|
| 163 |
+
continue
|
| 164 |
+
if not sent_role:
|
| 165 |
+
sent_role = True
|
| 166 |
+
yield completion_chunk(model, {"role": "assistant", "content": content}, None, completion_id, created)
|
| 167 |
+
else:
|
| 168 |
+
yield completion_chunk(model, {"content": content}, None, completion_id, created)
|
| 169 |
+
if not sent_role:
|
| 170 |
+
yield completion_chunk(model, {"role": "assistant", "content": ""}, None, completion_id, created)
|
| 171 |
+
yield completion_chunk(model, {}, "stop", completion_id, created)
|
| 172 |
+
|
| 173 |
+
|
| 174 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 175 |
+
if body.get("stream"):
|
| 176 |
+
if is_image_chat_request(body):
|
| 177 |
+
return image_chat_events(body)
|
| 178 |
+
model, messages = text_chat_parts(body)
|
| 179 |
+
return stream_text_chat_completion(text_backend(), messages, model)
|
| 180 |
+
if is_image_chat_request(body):
|
| 181 |
+
return image_chat_response(body)
|
| 182 |
+
model, messages = text_chat_parts(body)
|
| 183 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 184 |
+
return completion_response(model, collect_text(text_backend(), request), messages=messages)
|
services/protocol/openai_v1_image_edit.py
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Iterator
|
| 4 |
+
|
| 5 |
+
from services.protocol.conversation import (
|
| 6 |
+
ConversationRequest,
|
| 7 |
+
ImageGenerationError,
|
| 8 |
+
collect_image_outputs,
|
| 9 |
+
encode_images,
|
| 10 |
+
stream_image_chunks,
|
| 11 |
+
stream_image_outputs_with_pool,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 16 |
+
prompt = str(body.get("prompt") or "")
|
| 17 |
+
images = body.get("images") or []
|
| 18 |
+
model = str(body.get("model") or "gpt-image-2")
|
| 19 |
+
n = int(body.get("n") or 1)
|
| 20 |
+
size = body.get("size")
|
| 21 |
+
response_format = str(body.get("response_format") or "b64_json")
|
| 22 |
+
base_url = str(body.get("base_url") or "") or None
|
| 23 |
+
encoded_images = encode_images(images)
|
| 24 |
+
if not encoded_images:
|
| 25 |
+
raise ImageGenerationError("image is required")
|
| 26 |
+
outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 27 |
+
prompt=prompt,
|
| 28 |
+
model=model,
|
| 29 |
+
n=n,
|
| 30 |
+
size=size,
|
| 31 |
+
response_format=response_format,
|
| 32 |
+
base_url=base_url,
|
| 33 |
+
images=encoded_images,
|
| 34 |
+
message_as_error=True,
|
| 35 |
+
))
|
| 36 |
+
if body.get("stream"):
|
| 37 |
+
return stream_image_chunks(outputs)
|
| 38 |
+
return collect_image_outputs(outputs)
|
services/protocol/openai_v1_image_generations.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any, Iterator
|
| 4 |
+
|
| 5 |
+
from services.protocol.conversation import (
|
| 6 |
+
ConversationRequest,
|
| 7 |
+
collect_image_outputs,
|
| 8 |
+
stream_image_chunks,
|
| 9 |
+
stream_image_outputs_with_pool,
|
| 10 |
+
)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 14 |
+
prompt = str(body.get("prompt") or "")
|
| 15 |
+
model = str(body.get("model") or "gpt-image-2")
|
| 16 |
+
n = int(body.get("n") or 1)
|
| 17 |
+
size = body.get("size")
|
| 18 |
+
response_format = str(body.get("response_format") or "b64_json")
|
| 19 |
+
base_url = str(body.get("base_url") or "") or None
|
| 20 |
+
outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 21 |
+
prompt=prompt,
|
| 22 |
+
model=model,
|
| 23 |
+
n=n,
|
| 24 |
+
size=size,
|
| 25 |
+
response_format=response_format,
|
| 26 |
+
base_url=base_url,
|
| 27 |
+
message_as_error=True,
|
| 28 |
+
))
|
| 29 |
+
if body.get("stream"):
|
| 30 |
+
return stream_image_chunks(outputs)
|
| 31 |
+
return collect_image_outputs(outputs)
|
services/protocol/openai_v1_models.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
from typing import Any
|
| 4 |
+
|
| 5 |
+
from services.openai_backend_api import OpenAIBackendAPI
|
| 6 |
+
from utils.helper import IMAGE_MODELS
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def list_models() -> dict[str, Any]:
|
| 10 |
+
result = OpenAIBackendAPI().list_models()
|
| 11 |
+
data = result.get("data")
|
| 12 |
+
if not isinstance(data, list):
|
| 13 |
+
return result
|
| 14 |
+
seen = {str(item.get("id") or "").strip() for item in data if isinstance(item, dict)}
|
| 15 |
+
for model in sorted(IMAGE_MODELS):
|
| 16 |
+
if model not in seen:
|
| 17 |
+
data.append({
|
| 18 |
+
"id": model,
|
| 19 |
+
"object": "model",
|
| 20 |
+
"created": 0,
|
| 21 |
+
"owned_by": "chatgpt2api",
|
| 22 |
+
"permission": [],
|
| 23 |
+
"root": model,
|
| 24 |
+
"parent": None,
|
| 25 |
+
})
|
| 26 |
+
return result
|
services/protocol/openai_v1_response.py
ADDED
|
@@ -0,0 +1,216 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
import time
|
| 5 |
+
import uuid
|
| 6 |
+
from typing import Any, Iterable, Iterator
|
| 7 |
+
|
| 8 |
+
from fastapi import HTTPException
|
| 9 |
+
|
| 10 |
+
from services.protocol.conversation import (
|
| 11 |
+
ConversationRequest,
|
| 12 |
+
ImageOutput,
|
| 13 |
+
encode_images,
|
| 14 |
+
stream_image_outputs_with_pool,
|
| 15 |
+
stream_text_deltas,
|
| 16 |
+
text_backend,
|
| 17 |
+
)
|
| 18 |
+
from utils.helper import extract_image_from_message_content, extract_response_prompt, has_response_image_generation_tool
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def is_text_response_request(body: dict[str, Any]) -> bool:
|
| 22 |
+
return not has_response_image_generation_tool(body)
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
def extract_response_image(input_value: object) -> tuple[bytes, str] | None:
|
| 26 |
+
if isinstance(input_value, dict):
|
| 27 |
+
images = extract_image_from_message_content(input_value.get("content"))
|
| 28 |
+
return images[0] if images else None
|
| 29 |
+
if not isinstance(input_value, list):
|
| 30 |
+
return None
|
| 31 |
+
for item in reversed(input_value):
|
| 32 |
+
if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_image":
|
| 33 |
+
image_url = str(item.get("image_url") or "")
|
| 34 |
+
if image_url.startswith("data:"):
|
| 35 |
+
header, _, data = image_url.partition(",")
|
| 36 |
+
mime = header.split(";")[0].removeprefix("data:")
|
| 37 |
+
return base64.b64decode(data), mime or "image/png"
|
| 38 |
+
if isinstance(item, dict):
|
| 39 |
+
images = extract_image_from_message_content(item.get("content"))
|
| 40 |
+
if images:
|
| 41 |
+
return images[0]
|
| 42 |
+
return None
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def messages_from_input(input_value: object, instructions: object = None) -> list[dict[str, Any]]:
|
| 46 |
+
messages: list[dict[str, Any]] = []
|
| 47 |
+
system_text = str(instructions or "").strip()
|
| 48 |
+
if system_text:
|
| 49 |
+
messages.append({"role": "system", "content": system_text})
|
| 50 |
+
if isinstance(input_value, str):
|
| 51 |
+
if input_value.strip():
|
| 52 |
+
messages.append({"role": "user", "content": input_value.strip()})
|
| 53 |
+
return messages
|
| 54 |
+
if isinstance(input_value, dict):
|
| 55 |
+
messages.append({
|
| 56 |
+
"role": str(input_value.get("role") or "user"),
|
| 57 |
+
"content": extract_response_prompt([input_value]) or input_value.get("content") or "",
|
| 58 |
+
})
|
| 59 |
+
return messages
|
| 60 |
+
if isinstance(input_value, list):
|
| 61 |
+
if all(isinstance(item, dict) and item.get("type") for item in input_value):
|
| 62 |
+
text = extract_response_prompt(input_value)
|
| 63 |
+
if text:
|
| 64 |
+
messages.append({"role": "user", "content": text})
|
| 65 |
+
return messages
|
| 66 |
+
for item in input_value:
|
| 67 |
+
if isinstance(item, dict):
|
| 68 |
+
messages.append({
|
| 69 |
+
"role": str(item.get("role") or "user"),
|
| 70 |
+
"content": extract_response_prompt([item]) or item.get("content") or "",
|
| 71 |
+
})
|
| 72 |
+
return messages
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def text_output_item(text: str, item_id: str | None = None, status: str = "completed") -> dict[str, Any]:
|
| 76 |
+
return {
|
| 77 |
+
"id": item_id or f"msg_{uuid.uuid4().hex}",
|
| 78 |
+
"type": "message",
|
| 79 |
+
"status": status,
|
| 80 |
+
"role": "assistant",
|
| 81 |
+
"content": [{"type": "output_text", "text": text, "annotations": []}],
|
| 82 |
+
}
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
def image_output_items(prompt: str, data: list[dict[str, Any]], item_id: str | None = None) -> list[dict[str, Any]]:
|
| 86 |
+
output = []
|
| 87 |
+
for item in data:
|
| 88 |
+
b64_json = str(item.get("b64_json") or "").strip()
|
| 89 |
+
if b64_json:
|
| 90 |
+
output.append({
|
| 91 |
+
"id": item_id or f"ig_{len(output) + 1}",
|
| 92 |
+
"type": "image_generation_call",
|
| 93 |
+
"status": "completed",
|
| 94 |
+
"result": b64_json,
|
| 95 |
+
"revised_prompt": str(item.get("revised_prompt") or prompt).strip() or prompt,
|
| 96 |
+
})
|
| 97 |
+
return output
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
def response_created(response_id: str, model: str, created: int) -> dict[str, Any]:
|
| 101 |
+
return {
|
| 102 |
+
"type": "response.created",
|
| 103 |
+
"response": {
|
| 104 |
+
"id": response_id,
|
| 105 |
+
"object": "response",
|
| 106 |
+
"created_at": created,
|
| 107 |
+
"status": "in_progress",
|
| 108 |
+
"error": None,
|
| 109 |
+
"incomplete_details": None,
|
| 110 |
+
"model": model,
|
| 111 |
+
"output": [],
|
| 112 |
+
"parallel_tool_calls": False,
|
| 113 |
+
},
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
|
| 117 |
+
def response_completed(response_id: str, model: str, created: int, output: list[dict[str, Any]]) -> dict[str, Any]:
|
| 118 |
+
return {
|
| 119 |
+
"type": "response.completed",
|
| 120 |
+
"response": {
|
| 121 |
+
"id": response_id,
|
| 122 |
+
"object": "response",
|
| 123 |
+
"created_at": created,
|
| 124 |
+
"status": "completed",
|
| 125 |
+
"error": None,
|
| 126 |
+
"incomplete_details": None,
|
| 127 |
+
"model": model,
|
| 128 |
+
"output": output,
|
| 129 |
+
"parallel_tool_calls": False,
|
| 130 |
+
},
|
| 131 |
+
}
|
| 132 |
+
|
| 133 |
+
|
| 134 |
+
def stream_text_response(backend, body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 135 |
+
model = str(body.get("model") or "auto").strip() or "auto"
|
| 136 |
+
messages = messages_from_input(body.get("input"), body.get("instructions"))
|
| 137 |
+
response_id = f"resp_{uuid.uuid4().hex}"
|
| 138 |
+
item_id = f"msg_{uuid.uuid4().hex}"
|
| 139 |
+
created = int(time.time())
|
| 140 |
+
full_text = ""
|
| 141 |
+
yield response_created(response_id, model, created)
|
| 142 |
+
yield {"type": "response.output_item.added", "output_index": 0, "item": text_output_item("", item_id, "in_progress")}
|
| 143 |
+
request = ConversationRequest(model=model, messages=messages)
|
| 144 |
+
for delta in stream_text_deltas(backend, request):
|
| 145 |
+
full_text += delta
|
| 146 |
+
yield {"type": "response.output_text.delta", "item_id": item_id, "output_index": 0, "content_index": 0, "delta": delta}
|
| 147 |
+
yield {"type": "response.output_text.done", "item_id": item_id, "output_index": 0, "content_index": 0, "text": full_text}
|
| 148 |
+
item = text_output_item(full_text, item_id, "completed")
|
| 149 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 150 |
+
yield response_completed(response_id, model, created, [item])
|
| 151 |
+
|
| 152 |
+
|
| 153 |
+
def stream_image_response(image_outputs: Iterable[ImageOutput], prompt: str, model: str) -> Iterator[dict[str, Any]]:
|
| 154 |
+
response_id = f"resp_{uuid.uuid4().hex}"
|
| 155 |
+
created = int(time.time())
|
| 156 |
+
yield response_created(response_id, model, created)
|
| 157 |
+
for output in image_outputs:
|
| 158 |
+
if output.kind == "message":
|
| 159 |
+
text = output.text
|
| 160 |
+
item = text_output_item(text)
|
| 161 |
+
yield {"type": "response.output_text.delta", "item_id": item["id"], "output_index": 0, "content_index": 0, "delta": text}
|
| 162 |
+
yield {"type": "response.output_text.done", "item_id": item["id"], "output_index": 0, "content_index": 0, "text": text}
|
| 163 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 164 |
+
yield response_completed(response_id, model, created, [item])
|
| 165 |
+
return
|
| 166 |
+
if output.kind != "result":
|
| 167 |
+
continue
|
| 168 |
+
items = image_output_items(prompt, output.data)
|
| 169 |
+
if items:
|
| 170 |
+
item = items[0]
|
| 171 |
+
yield {"type": "response.output_item.done", "output_index": 0, "item": item}
|
| 172 |
+
yield response_completed(response_id, model, created, [item])
|
| 173 |
+
return
|
| 174 |
+
raise RuntimeError("image generation failed")
|
| 175 |
+
|
| 176 |
+
|
| 177 |
+
def collect_response(events: Iterable[dict[str, Any]]) -> dict[str, Any]:
|
| 178 |
+
completed = {}
|
| 179 |
+
for event in events:
|
| 180 |
+
if event.get("type") == "response.completed":
|
| 181 |
+
completed = event.get("response") if isinstance(event.get("response"), dict) else {}
|
| 182 |
+
if not completed:
|
| 183 |
+
raise RuntimeError("response generation failed")
|
| 184 |
+
return completed
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
def response_events(body: dict[str, Any]) -> Iterator[dict[str, Any]]:
|
| 188 |
+
if is_text_response_request(body):
|
| 189 |
+
yield from stream_text_response(text_backend(), body)
|
| 190 |
+
return
|
| 191 |
+
|
| 192 |
+
prompt = extract_response_prompt(body.get("input"))
|
| 193 |
+
if not prompt:
|
| 194 |
+
raise HTTPException(status_code=400, detail={"error": "input text is required"})
|
| 195 |
+
model = str(body.get("model") or "gpt-image-2").strip() or "gpt-image-2"
|
| 196 |
+
image_info = extract_response_image(body.get("input"))
|
| 197 |
+
if image_info:
|
| 198 |
+
image_data, mime_type = image_info
|
| 199 |
+
images = encode_images([(image_data, "image.png", mime_type)])
|
| 200 |
+
else:
|
| 201 |
+
images = None
|
| 202 |
+
image_outputs = stream_image_outputs_with_pool(ConversationRequest(
|
| 203 |
+
prompt=prompt,
|
| 204 |
+
model=model,
|
| 205 |
+
size=None if images else "1:1",
|
| 206 |
+
response_format="b64_json",
|
| 207 |
+
images=images,
|
| 208 |
+
))
|
| 209 |
+
yield from stream_image_response(image_outputs, prompt, model)
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
def handle(body: dict[str, Any]) -> dict[str, Any] | Iterator[dict[str, Any]]:
|
| 213 |
+
events = response_events(body)
|
| 214 |
+
if body.get("stream"):
|
| 215 |
+
return events
|
| 216 |
+
return collect_response(events)
|
test/test_generations.py
CHANGED
|
@@ -3,7 +3,7 @@ from test.utils import post_json, save_image
|
|
| 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-
|
| 7 |
for index, item in enumerate(result["data"], start=1):
|
| 8 |
print(save_image(item["b64_json"], f"generations_{index}"))
|
| 9 |
|
|
|
|
| 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-2", "n": 1})
|
| 7 |
for index, item in enumerate(result["data"], start=1):
|
| 8 |
print(save_image(item["b64_json"], f"generations_{index}"))
|
| 9 |
|
test/test_generations_url.py
CHANGED
|
@@ -6,7 +6,7 @@ def main() -> None:
|
|
| 6 |
"/v1/images/generations",
|
| 7 |
{
|
| 8 |
"prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
|
| 9 |
-
"model": "gpt-image-
|
| 10 |
"n": 1,
|
| 11 |
"response_format": "url",
|
| 12 |
},
|
|
|
|
| 6 |
"/v1/images/generations",
|
| 7 |
{
|
| 8 |
"prompt": "一只橘猫坐在窗边,午后阳光,写实摄影",
|
| 9 |
+
"model": "gpt-image-2",
|
| 10 |
"n": 1,
|
| 11 |
"response_format": "url",
|
| 12 |
},
|
test/test_image.py
CHANGED
|
@@ -1,13 +1,12 @@
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
-
from services.
|
| 4 |
-
from services.chatgpt_service import ChatGPTService
|
| 5 |
from test.utils import save_image
|
| 6 |
|
| 7 |
|
| 8 |
def main() -> None:
|
| 9 |
prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
|
| 10 |
-
data =
|
| 11 |
for index, item in enumerate(data["data"], start=1):
|
| 12 |
print(save_image(item["b64_json"], f"image_{index}"))
|
| 13 |
|
|
|
|
| 1 |
from __future__ import annotations
|
| 2 |
|
| 3 |
+
from services.protocol import openai_v1_image_generations
|
|
|
|
| 4 |
from test.utils import save_image
|
| 5 |
|
| 6 |
|
| 7 |
def main() -> None:
|
| 8 |
prompt = "一只橘猫坐在窗台上,午后阳光,写实摄影"
|
| 9 |
+
data = openai_v1_image_generations.handle({"prompt": prompt, "model": "gpt-5-3", "n": 1})
|
| 10 |
for index, item in enumerate(data["data"], start=1):
|
| 11 |
print(save_image(item["b64_json"], f"image_{index}"))
|
| 12 |
|
test/test_v1_chat_completions.py
CHANGED
|
@@ -63,7 +63,7 @@ class ChatCompletionsTests(unittest.TestCase):
|
|
| 63 |
f"{BASE_URL}/v1/chat/completions",
|
| 64 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 65 |
json={
|
| 66 |
-
"model": "gpt-image-
|
| 67 |
"messages": [
|
| 68 |
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
|
| 69 |
],
|
|
@@ -86,7 +86,7 @@ class ChatCompletionsTests(unittest.TestCase):
|
|
| 86 |
f"{BASE_URL}/v1/chat/completions",
|
| 87 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 88 |
json={
|
| 89 |
-
"model": "gpt-image-
|
| 90 |
"stream": True,
|
| 91 |
"messages": [
|
| 92 |
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
|
|
|
|
| 63 |
f"{BASE_URL}/v1/chat/completions",
|
| 64 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 65 |
json={
|
| 66 |
+
"model": "gpt-image-2",
|
| 67 |
"messages": [
|
| 68 |
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
|
| 69 |
],
|
|
|
|
| 86 |
f"{BASE_URL}/v1/chat/completions",
|
| 87 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 88 |
json={
|
| 89 |
+
"model": "gpt-image-2",
|
| 90 |
"stream": True,
|
| 91 |
"messages": [
|
| 92 |
{"role": "user", "content": "我想做一张南京城市宣传海报图。"},
|
test/test_v1_images_edits.py
CHANGED
|
@@ -42,7 +42,7 @@ class ImageEditsTests(unittest.TestCase):
|
|
| 42 |
f"{BASE_URL}/v1/images/edits",
|
| 43 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 44 |
data={
|
| 45 |
-
"model": "gpt-image-
|
| 46 |
"prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
|
| 47 |
"n": "1",
|
| 48 |
"response_format": "b64_json",
|
|
@@ -72,7 +72,7 @@ class ImageEditsTests(unittest.TestCase):
|
|
| 72 |
f"{BASE_URL}/v1/images/edits",
|
| 73 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 74 |
data={
|
| 75 |
-
"model": "gpt-image-
|
| 76 |
"prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
|
| 77 |
"n": "1",
|
| 78 |
"response_format": "b64_json",
|
|
|
|
| 42 |
f"{BASE_URL}/v1/images/edits",
|
| 43 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 44 |
data={
|
| 45 |
+
"model": "gpt-image-2",
|
| 46 |
"prompt": "参考输入图片,保持人物主体和二次元插画风格不变,让女孩怀里抱着一只可爱的小猫,画面自然协调。",
|
| 47 |
"n": "1",
|
| 48 |
"response_format": "b64_json",
|
|
|
|
| 72 |
f"{BASE_URL}/v1/images/edits",
|
| 73 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 74 |
data={
|
| 75 |
+
"model": "gpt-image-2",
|
| 76 |
"prompt": "请提取两张输入界面截图中的 6 个任务,并把这 6 个任务整合排版到同一张图里,做成一张清晰的中文任务总览海报,标题明确,六个任务分区展示,版面整洁。",
|
| 77 |
"n": "1",
|
| 78 |
"response_format": "b64_json",
|
test/test_v1_images_generations.py
CHANGED
|
@@ -19,7 +19,7 @@ class ImageGenerationsTests(unittest.TestCase):
|
|
| 19 |
f"{BASE_URL}/v1/images/generations",
|
| 20 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 21 |
json={
|
| 22 |
-
"model": "gpt-image-
|
| 23 |
"prompt": "我想做一张南京城市宣传海报图。",
|
| 24 |
"n": 1,
|
| 25 |
"response_format": "b64_json",
|
|
@@ -46,7 +46,7 @@ class ImageGenerationsTests(unittest.TestCase):
|
|
| 46 |
f"{BASE_URL}/v1/images/generations",
|
| 47 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 48 |
json={
|
| 49 |
-
"model": "gpt-image-
|
| 50 |
"prompt": "我想做一张南京城市宣传海报图。",
|
| 51 |
"n": 1,
|
| 52 |
"response_format": "b64_json",
|
|
|
|
| 19 |
f"{BASE_URL}/v1/images/generations",
|
| 20 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 21 |
json={
|
| 22 |
+
"model": "gpt-image-2",
|
| 23 |
"prompt": "我想做一张南京城市宣传海报图。",
|
| 24 |
"n": 1,
|
| 25 |
"response_format": "b64_json",
|
|
|
|
| 46 |
f"{BASE_URL}/v1/images/generations",
|
| 47 |
headers={"Authorization": f"Bearer {AUTH_KEY}"},
|
| 48 |
json={
|
| 49 |
+
"model": "gpt-image-2",
|
| 50 |
"prompt": "我想做一张南京城市宣传海报图。",
|
| 51 |
"n": 1,
|
| 52 |
"response_format": "b64_json",
|
test/test_v1_messages.py
ADDED
|
@@ -0,0 +1,86 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
|
| 3 |
+
import json
|
| 4 |
+
import time
|
| 5 |
+
import unittest
|
| 6 |
+
|
| 7 |
+
import requests
|
| 8 |
+
|
| 9 |
+
AUTH_KEY = "chatgpt2api"
|
| 10 |
+
BASE_URL = "http://localhost:8000"
|
| 11 |
+
MODEL = "auto"
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
class AnthropicMessagesTests(unittest.TestCase):
|
| 15 |
+
@staticmethod
|
| 16 |
+
def _headers() -> dict[str, str]:
|
| 17 |
+
return {
|
| 18 |
+
"x-api-key": AUTH_KEY,
|
| 19 |
+
"anthropic-version": "2023-06-01",
|
| 20 |
+
}
|
| 21 |
+
|
| 22 |
+
def test_message_http(self):
|
| 23 |
+
"""测试 Anthropic Messages 的非流式 HTTP 调用。"""
|
| 24 |
+
response = requests.post(
|
| 25 |
+
f"{BASE_URL}/v1/messages",
|
| 26 |
+
headers=self._headers(),
|
| 27 |
+
json={
|
| 28 |
+
"model": MODEL,
|
| 29 |
+
"messages": [
|
| 30 |
+
{"role": "user", "content": "你好,请简单介绍一下你自己。"},
|
| 31 |
+
],
|
| 32 |
+
},
|
| 33 |
+
timeout=300,
|
| 34 |
+
)
|
| 35 |
+
print("messages non-stream status:")
|
| 36 |
+
print(response.status_code)
|
| 37 |
+
print("messages non-stream result:")
|
| 38 |
+
try:
|
| 39 |
+
print(json.dumps(response.json(), ensure_ascii=False, indent=2))
|
| 40 |
+
except Exception:
|
| 41 |
+
print(response.text)
|
| 42 |
+
|
| 43 |
+
def test_message_stream_http(self):
|
| 44 |
+
"""测试 Anthropic Messages 的流式 HTTP 调用。"""
|
| 45 |
+
started_at = time.time()
|
| 46 |
+
response = requests.post(
|
| 47 |
+
f"{BASE_URL}/v1/messages",
|
| 48 |
+
headers=self._headers(),
|
| 49 |
+
json={
|
| 50 |
+
"model": MODEL,
|
| 51 |
+
"stream": True,
|
| 52 |
+
"messages": [
|
| 53 |
+
{"role": "user", "content": "你好,请简单介绍一下你自己。"},
|
| 54 |
+
],
|
| 55 |
+
},
|
| 56 |
+
stream=True,
|
| 57 |
+
timeout=300,
|
| 58 |
+
)
|
| 59 |
+
headers_at = time.time()
|
| 60 |
+
print("messages stream status:")
|
| 61 |
+
print(response.status_code)
|
| 62 |
+
print("messages stream content-type:")
|
| 63 |
+
print(response.headers.get("content-type", ""))
|
| 64 |
+
print("messages stream response headers:")
|
| 65 |
+
print(f"{headers_at - started_at:6.2f}s")
|
| 66 |
+
if response.status_code != 200:
|
| 67 |
+
print(response.text)
|
| 68 |
+
return
|
| 69 |
+
print("messages stream chunks:")
|
| 70 |
+
for line in response.iter_lines(chunk_size=1):
|
| 71 |
+
if not line:
|
| 72 |
+
continue
|
| 73 |
+
text = line.decode("utf-8", errors="replace")
|
| 74 |
+
print(f"{time.time() - started_at:6.2f}s {text}")
|
| 75 |
+
if not text.startswith("data:"):
|
| 76 |
+
continue
|
| 77 |
+
try:
|
| 78 |
+
payload = json.loads(text[5:].strip())
|
| 79 |
+
except Exception:
|
| 80 |
+
continue
|
| 81 |
+
if payload.get("type") == "message_stop":
|
| 82 |
+
break
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
if __name__ == "__main__":
|
| 86 |
+
unittest.main()
|
test/test_v1_models.py
CHANGED
|
@@ -5,7 +5,7 @@ import unittest
|
|
| 5 |
|
| 6 |
import requests
|
| 7 |
|
| 8 |
-
from services.
|
| 9 |
|
| 10 |
|
| 11 |
AUTH_KEY = "chatgpt2api"
|
|
@@ -15,7 +15,7 @@ BASE_URL = "http://localhost:8000"
|
|
| 15 |
class ModelListTests(unittest.TestCase):
|
| 16 |
def test_list_models_function(self):
|
| 17 |
"""测试直接调用服务层获取模型列表。"""
|
| 18 |
-
result =
|
| 19 |
print("function result:")
|
| 20 |
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 21 |
|
|
|
|
| 5 |
|
| 6 |
import requests
|
| 7 |
|
| 8 |
+
from services.protocol import openai_v1_models
|
| 9 |
|
| 10 |
|
| 11 |
AUTH_KEY = "chatgpt2api"
|
|
|
|
| 15 |
class ModelListTests(unittest.TestCase):
|
| 16 |
def test_list_models_function(self):
|
| 17 |
"""测试直接调用服务层获取模型列表。"""
|
| 18 |
+
result = openai_v1_models.list_models()
|
| 19 |
print("function result:")
|
| 20 |
print(json.dumps(result, ensure_ascii=False, indent=2))
|
| 21 |
|
test/test_v1_responses.py
CHANGED
|
@@ -11,7 +11,7 @@ from test.utils import save_image
|
|
| 11 |
AUTH_KEY = "chatgpt2api"
|
| 12 |
BASE_URL = "http://localhost:8000"
|
| 13 |
TEXT_MODEL = "auto"
|
| 14 |
-
IMAGE_MODEL = "gpt-image-
|
| 15 |
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
| 16 |
|
| 17 |
|
|
|
|
| 11 |
AUTH_KEY = "chatgpt2api"
|
| 12 |
BASE_URL = "http://localhost:8000"
|
| 13 |
TEXT_MODEL = "auto"
|
| 14 |
+
IMAGE_MODEL = "gpt-image-2"
|
| 15 |
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
| 16 |
|
| 17 |
|
utils/helper.py
CHANGED
|
@@ -1,13 +1,13 @@
|
|
| 1 |
import base64
|
| 2 |
-
import json
|
| 3 |
import hashlib
|
| 4 |
-
import
|
|
|
|
| 5 |
import time
|
|
|
|
| 6 |
from pathlib import Path
|
| 7 |
-
from typing import Any,
|
| 8 |
|
| 9 |
from curl_cffi import requests
|
| 10 |
-
import re
|
| 11 |
from fastapi import HTTPException
|
| 12 |
from utils.log import logger
|
| 13 |
|
|
@@ -24,10 +24,7 @@ def is_image_chat_request(body: dict[str, object]) -> bool:
|
|
| 24 |
modalities = body.get("modalities")
|
| 25 |
if model in IMAGE_MODELS:
|
| 26 |
return True
|
| 27 |
-
|
| 28 |
-
normalized = {str(item or "").strip().lower() for item in modalities}
|
| 29 |
-
return "image" in normalized
|
| 30 |
-
return False
|
| 31 |
|
| 32 |
|
| 33 |
def ensure_ok(response: requests.Response, context: str) -> None:
|
|
@@ -41,23 +38,6 @@ def ensure_ok(response: requests.Response, context: str) -> None:
|
|
| 41 |
raise RuntimeError(f"{context} failed: status={response.status_code}, body={body}")
|
| 42 |
|
| 43 |
|
| 44 |
-
def parse_sse_lines(response: requests.Response) -> Iterator[Dict[str, Any]]:
|
| 45 |
-
for raw_line in response.iter_lines():
|
| 46 |
-
if not raw_line:
|
| 47 |
-
continue
|
| 48 |
-
line = raw_line.decode("utf-8", errors="ignore")
|
| 49 |
-
if not line.startswith("data:"):
|
| 50 |
-
continue
|
| 51 |
-
payload = line[5:].strip()
|
| 52 |
-
if payload == "[DONE]":
|
| 53 |
-
yield {"done": True}
|
| 54 |
-
break
|
| 55 |
-
try:
|
| 56 |
-
yield json.loads(payload)
|
| 57 |
-
except json.JSONDecodeError:
|
| 58 |
-
yield {"raw": payload}
|
| 59 |
-
|
| 60 |
-
|
| 61 |
def sse_json_stream(items) -> Iterator[str]:
|
| 62 |
yield ": stream-open\n\n"
|
| 63 |
try:
|
|
@@ -69,7 +49,10 @@ def sse_json_stream(items) -> Iterator[str]:
|
|
| 69 |
"error_type": exc.__class__.__name__,
|
| 70 |
"error": str(exc),
|
| 71 |
})
|
| 72 |
-
|
|
|
|
|
|
|
|
|
|
| 73 |
yield "data: [DONE]\n\n"
|
| 74 |
|
| 75 |
|
|
@@ -90,6 +73,18 @@ def anthropic_sse_stream(items) -> Iterator[str]:
|
|
| 90 |
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
|
| 91 |
|
| 92 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 93 |
def save_images_from_text(text: str, prefix: str) -> list[Path]:
|
| 94 |
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 95 |
matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
|
|
@@ -240,22 +235,6 @@ def parse_image_count(raw_value: object) -> int:
|
|
| 240 |
return value
|
| 241 |
|
| 242 |
|
| 243 |
-
def build_chat_image_completion(model: str, image_result: dict[str, object]) -> dict[str, object]:
|
| 244 |
-
created = int(image_result.get("created") or time.time())
|
| 245 |
-
return {
|
| 246 |
-
"id": f"chatcmpl-{uuid.uuid4().hex}",
|
| 247 |
-
"object": "chat.completion",
|
| 248 |
-
"created": created,
|
| 249 |
-
"model": model,
|
| 250 |
-
"choices": [{
|
| 251 |
-
"index": 0,
|
| 252 |
-
"message": {"role": "assistant", "content": build_chat_image_markdown_content(image_result)},
|
| 253 |
-
"finish_reason": "stop",
|
| 254 |
-
}],
|
| 255 |
-
"usage": {"prompt_tokens": 0, "completion_tokens": 0, "total_tokens": 0},
|
| 256 |
-
}
|
| 257 |
-
|
| 258 |
-
|
| 259 |
def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
|
| 260 |
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 261 |
markdown_images: list[str] = []
|
|
|
|
| 1 |
import base64
|
|
|
|
| 2 |
import hashlib
|
| 3 |
+
import json
|
| 4 |
+
import re
|
| 5 |
import time
|
| 6 |
+
import uuid
|
| 7 |
from pathlib import Path
|
| 8 |
+
from typing import Any, Iterator
|
| 9 |
|
| 10 |
from curl_cffi import requests
|
|
|
|
| 11 |
from fastapi import HTTPException
|
| 12 |
from utils.log import logger
|
| 13 |
|
|
|
|
| 24 |
modalities = body.get("modalities")
|
| 25 |
if model in IMAGE_MODELS:
|
| 26 |
return True
|
| 27 |
+
return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
|
|
|
|
|
|
|
|
|
|
| 28 |
|
| 29 |
|
| 30 |
def ensure_ok(response: requests.Response, context: str) -> None:
|
|
|
|
| 38 |
raise RuntimeError(f"{context} failed: status={response.status_code}, body={body}")
|
| 39 |
|
| 40 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 41 |
def sse_json_stream(items) -> Iterator[str]:
|
| 42 |
yield ": stream-open\n\n"
|
| 43 |
try:
|
|
|
|
| 49 |
"error_type": exc.__class__.__name__,
|
| 50 |
"error": str(exc),
|
| 51 |
})
|
| 52 |
+
error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else {
|
| 53 |
+
"error": {"message": str(exc), "type": exc.__class__.__name__}
|
| 54 |
+
}
|
| 55 |
+
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
|
| 56 |
yield "data: [DONE]\n\n"
|
| 57 |
|
| 58 |
|
|
|
|
| 73 |
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
|
| 74 |
|
| 75 |
|
| 76 |
+
def iter_sse_payloads(response: requests.Response) -> Iterator[str]:
|
| 77 |
+
for raw_line in response.iter_lines():
|
| 78 |
+
if not raw_line:
|
| 79 |
+
continue
|
| 80 |
+
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
|
| 81 |
+
if not line.startswith("data:"):
|
| 82 |
+
continue
|
| 83 |
+
payload = line[5:].strip()
|
| 84 |
+
if payload:
|
| 85 |
+
yield payload
|
| 86 |
+
|
| 87 |
+
|
| 88 |
def save_images_from_text(text: str, prefix: str) -> list[Path]:
|
| 89 |
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
| 90 |
matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
|
|
|
|
| 235 |
return value
|
| 236 |
|
| 237 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 238 |
def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
|
| 239 |
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
| 240 |
markdown_images: list[str] = []
|
utils/log.py
CHANGED
|
@@ -13,11 +13,19 @@ class Logger:
|
|
| 13 |
self._logger = logging.getLogger(name)
|
| 14 |
if not self._logger.handlers:
|
| 15 |
handler = logging.StreamHandler()
|
| 16 |
-
handler.setFormatter(logging.Formatter("%(message)s"))
|
| 17 |
self._logger.addHandler(handler)
|
| 18 |
-
self._logger.setLevel(logging.
|
| 19 |
self._logger.propagate = False
|
| 20 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
def _mask_string(self, value: str, keep: int = 10) -> str:
|
| 22 |
if len(value) <= keep:
|
| 23 |
return value
|
|
@@ -76,16 +84,20 @@ class Logger:
|
|
| 76 |
return value
|
| 77 |
|
| 78 |
def debug(self, message: Any) -> None:
|
| 79 |
-
self.
|
|
|
|
| 80 |
|
| 81 |
def info(self, message: Any) -> None:
|
| 82 |
-
self.
|
|
|
|
| 83 |
|
| 84 |
def warning(self, message: Any) -> None:
|
| 85 |
-
self.
|
|
|
|
| 86 |
|
| 87 |
def error(self, message: Any) -> None:
|
| 88 |
-
self.
|
|
|
|
| 89 |
|
| 90 |
|
| 91 |
logger = Logger()
|
|
|
|
| 13 |
self._logger = logging.getLogger(name)
|
| 14 |
if not self._logger.handlers:
|
| 15 |
handler = logging.StreamHandler()
|
| 16 |
+
handler.setFormatter(logging.Formatter("[%(levelname)s] %(message)s"))
|
| 17 |
self._logger.addHandler(handler)
|
| 18 |
+
self._logger.setLevel(logging.DEBUG)
|
| 19 |
self._logger.propagate = False
|
| 20 |
|
| 21 |
+
def _enabled(self, level: str) -> bool:
|
| 22 |
+
try:
|
| 23 |
+
from services.config import config
|
| 24 |
+
levels = set(config.log_levels)
|
| 25 |
+
except Exception:
|
| 26 |
+
levels = set()
|
| 27 |
+
return level in (levels or {"info", "warning", "error"})
|
| 28 |
+
|
| 29 |
def _mask_string(self, value: str, keep: int = 10) -> str:
|
| 30 |
if len(value) <= keep:
|
| 31 |
return value
|
|
|
|
| 84 |
return value
|
| 85 |
|
| 86 |
def debug(self, message: Any) -> None:
|
| 87 |
+
if self._enabled("debug"):
|
| 88 |
+
self._logger.debug(self._sanitize(message))
|
| 89 |
|
| 90 |
def info(self, message: Any) -> None:
|
| 91 |
+
if self._enabled("info"):
|
| 92 |
+
self._logger.info(self._sanitize(message))
|
| 93 |
|
| 94 |
def warning(self, message: Any) -> None:
|
| 95 |
+
if self._enabled("warning"):
|
| 96 |
+
self._logger.warning(self._sanitize(message))
|
| 97 |
|
| 98 |
def error(self, message: Any) -> None:
|
| 99 |
+
if self._enabled("error"):
|
| 100 |
+
self._logger.error(self._sanitize(message))
|
| 101 |
|
| 102 |
|
| 103 |
logger = Logger()
|
uv.lock
CHANGED
|
@@ -325,29 +325,29 @@ wheels = [
|
|
| 325 |
[[package]]
|
| 326 |
name = "httpcore"
|
| 327 |
version = "1.0.9"
|
| 328 |
-
source = { registry = "https://
|
| 329 |
dependencies = [
|
| 330 |
{ name = "certifi" },
|
| 331 |
{ name = "h11" },
|
| 332 |
]
|
| 333 |
-
sdist = { url = "https://
|
| 334 |
wheels = [
|
| 335 |
-
{ url = "https://
|
| 336 |
]
|
| 337 |
|
| 338 |
[[package]]
|
| 339 |
name = "httpx"
|
| 340 |
version = "0.28.1"
|
| 341 |
-
source = { registry = "https://
|
| 342 |
dependencies = [
|
| 343 |
{ name = "anyio" },
|
| 344 |
{ name = "certifi" },
|
| 345 |
{ name = "httpcore" },
|
| 346 |
{ name = "idna" },
|
| 347 |
]
|
| 348 |
-
sdist = { url = "https://
|
| 349 |
wheels = [
|
| 350 |
-
{ url = "https://
|
| 351 |
]
|
| 352 |
|
| 353 |
[[package]]
|
|
|
|
| 325 |
[[package]]
|
| 326 |
name = "httpcore"
|
| 327 |
version = "1.0.9"
|
| 328 |
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
| 329 |
dependencies = [
|
| 330 |
{ name = "certifi" },
|
| 331 |
{ name = "h11" },
|
| 332 |
]
|
| 333 |
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/06/94/82699a10bca87a5556c9c59b5963f2d039dbd239f25bc2a63907a05a14cb/httpcore-1.0.9.tar.gz", hash = "sha256:6e34463af53fd2ab5d807f399a9b45ea31c3dfa2276f15a2c3f00afff6e176e8" }
|
| 334 |
wheels = [
|
| 335 |
+
{ url = "https://mirrors.aliyun.com/pypi/packages/7e/f5/f66802a942d491edb555dd61e3a9961140fd64c90bce1eafd741609d334d/httpcore-1.0.9-py3-none-any.whl", hash = "sha256:2d400746a40668fc9dec9810239072b40b4484b640a8c38fd654a024c7a1bf55" },
|
| 336 |
]
|
| 337 |
|
| 338 |
[[package]]
|
| 339 |
name = "httpx"
|
| 340 |
version = "0.28.1"
|
| 341 |
+
source = { registry = "https://mirrors.aliyun.com/pypi/simple" }
|
| 342 |
dependencies = [
|
| 343 |
{ name = "anyio" },
|
| 344 |
{ name = "certifi" },
|
| 345 |
{ name = "httpcore" },
|
| 346 |
{ name = "idna" },
|
| 347 |
]
|
| 348 |
+
sdist = { url = "https://mirrors.aliyun.com/pypi/packages/b1/df/48c586a5fe32a0f01324ee087459e112ebb7224f646c0b5023f5e79e9956/httpx-0.28.1.tar.gz", hash = "sha256:75e98c5f16b0f35b567856f597f06ff2270a374470a5c2392242528e3e3e42fc" }
|
| 349 |
wheels = [
|
| 350 |
+
{ url = "https://mirrors.aliyun.com/pypi/packages/2a/39/e50c7c3a983047577ee07d2a9e53faf5a69493943ec3f6a384bdc792deb2/httpx-0.28.1-py3-none-any.whl", hash = "sha256:d909fcccc110f8c7faf814ca82a9a4d816bc5a6dbfea25d6591d6985b8ba59ad" },
|
| 351 |
]
|
| 352 |
|
| 353 |
[[package]]
|
web/src/app/image/components/image-results.tsx
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"use client";
|
| 2 |
|
|
|
|
| 3 |
import { Clock3, LoaderCircle, Sparkles } from "lucide-react";
|
| 4 |
|
| 5 |
import { Button } from "@/components/ui/button";
|
|
@@ -9,6 +10,8 @@ import type { ImageConversation, ImageTurnStatus, StoredImage, StoredReferenceIm
|
|
| 9 |
export type ImageLightboxItem = {
|
| 10 |
id: string;
|
| 11 |
src: string;
|
|
|
|
|
|
|
| 12 |
};
|
| 13 |
|
| 14 |
type ImageResultsProps = {
|
|
@@ -24,6 +27,18 @@ export function ImageResults({
|
|
| 24 |
onContinueEdit,
|
| 25 |
formatConversationTime,
|
| 26 |
}: ImageResultsProps) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
if (!selectedConversation) {
|
| 28 |
return (
|
| 29 |
<div className="flex h-full min-h-[420px] items-center justify-center text-center">
|
|
@@ -58,7 +73,14 @@ export function ImageResults({
|
|
| 58 |
}));
|
| 59 |
const successfulTurnImages = turn.images.flatMap((image) =>
|
| 60 |
image.status === "success" && image.b64_json
|
| 61 |
-
? [
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
: [],
|
| 63 |
);
|
| 64 |
|
|
@@ -125,6 +147,9 @@ export function ImageResults({
|
|
| 125 |
{turn.images.map((image, index) => {
|
| 126 |
if (image.status === "success" && image.b64_json) {
|
| 127 |
const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
|
|
|
|
|
|
|
|
|
|
| 128 |
|
| 129 |
return (
|
| 130 |
<div
|
|
@@ -140,10 +165,20 @@ export function ImageResults({
|
|
| 140 |
src={`data:image/png;base64,${image.b64_json}`}
|
| 141 |
alt={`Generated result ${index + 1}`}
|
| 142 |
className="block h-auto w-full transition duration-200 group-hover:brightness-90"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
/>
|
| 144 |
</button>
|
| 145 |
<div className="flex items-center justify-between gap-2 px-3 py-3">
|
| 146 |
-
<div className="text-xs text-stone-500">
|
|
|
|
|
|
|
|
|
|
| 147 |
<Button
|
| 148 |
variant="outline"
|
| 149 |
size="sm"
|
|
@@ -233,3 +268,21 @@ function getTurnStatusLabel(status: ImageTurnStatus) {
|
|
| 233 |
}
|
| 234 |
return "失败";
|
| 235 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
"use client";
|
| 2 |
|
| 3 |
+
import { useState } from "react";
|
| 4 |
import { Clock3, LoaderCircle, Sparkles } from "lucide-react";
|
| 5 |
|
| 6 |
import { Button } from "@/components/ui/button";
|
|
|
|
| 10 |
export type ImageLightboxItem = {
|
| 11 |
id: string;
|
| 12 |
src: string;
|
| 13 |
+
sizeLabel?: string;
|
| 14 |
+
dimensions?: string;
|
| 15 |
};
|
| 16 |
|
| 17 |
type ImageResultsProps = {
|
|
|
|
| 27 |
onContinueEdit,
|
| 28 |
formatConversationTime,
|
| 29 |
}: ImageResultsProps) {
|
| 30 |
+
const [imageDimensions, setImageDimensions] = useState<Record<string, string>>({});
|
| 31 |
+
|
| 32 |
+
const updateImageDimensions = (id: string, width: number, height: number) => {
|
| 33 |
+
const dimensions = formatImageDimensions(width, height);
|
| 34 |
+
setImageDimensions((current) => {
|
| 35 |
+
if (current[id] === dimensions) {
|
| 36 |
+
return current;
|
| 37 |
+
}
|
| 38 |
+
return { ...current, [id]: dimensions };
|
| 39 |
+
});
|
| 40 |
+
};
|
| 41 |
+
|
| 42 |
if (!selectedConversation) {
|
| 43 |
return (
|
| 44 |
<div className="flex h-full min-h-[420px] items-center justify-center text-center">
|
|
|
|
| 73 |
}));
|
| 74 |
const successfulTurnImages = turn.images.flatMap((image) =>
|
| 75 |
image.status === "success" && image.b64_json
|
| 76 |
+
? [
|
| 77 |
+
{
|
| 78 |
+
id: image.id,
|
| 79 |
+
src: `data:image/png;base64,${image.b64_json}`,
|
| 80 |
+
sizeLabel: formatBase64ImageSize(image.b64_json),
|
| 81 |
+
dimensions: imageDimensions[image.id],
|
| 82 |
+
},
|
| 83 |
+
]
|
| 84 |
: [],
|
| 85 |
);
|
| 86 |
|
|
|
|
| 147 |
{turn.images.map((image, index) => {
|
| 148 |
if (image.status === "success" && image.b64_json) {
|
| 149 |
const currentIndex = successfulTurnImages.findIndex((item) => item.id === image.id);
|
| 150 |
+
const sizeLabel = formatBase64ImageSize(image.b64_json);
|
| 151 |
+
const dimensions = imageDimensions[image.id];
|
| 152 |
+
const imageMeta = [sizeLabel, dimensions].filter(Boolean).join(" · ");
|
| 153 |
|
| 154 |
return (
|
| 155 |
<div
|
|
|
|
| 165 |
src={`data:image/png;base64,${image.b64_json}`}
|
| 166 |
alt={`Generated result ${index + 1}`}
|
| 167 |
className="block h-auto w-full transition duration-200 group-hover:brightness-90"
|
| 168 |
+
onLoad={(event) => {
|
| 169 |
+
updateImageDimensions(
|
| 170 |
+
image.id,
|
| 171 |
+
event.currentTarget.naturalWidth,
|
| 172 |
+
event.currentTarget.naturalHeight,
|
| 173 |
+
);
|
| 174 |
+
}}
|
| 175 |
/>
|
| 176 |
</button>
|
| 177 |
<div className="flex items-center justify-between gap-2 px-3 py-3">
|
| 178 |
+
<div className="min-w-0 text-xs text-stone-500">
|
| 179 |
+
<span>结果 {index + 1}</span>
|
| 180 |
+
{imageMeta ? <span className="ml-2 text-stone-400">{imageMeta}</span> : null}
|
| 181 |
+
</div>
|
| 182 |
<Button
|
| 183 |
variant="outline"
|
| 184 |
size="sm"
|
|
|
|
| 268 |
}
|
| 269 |
return "失败";
|
| 270 |
}
|
| 271 |
+
|
| 272 |
+
function formatBase64ImageSize(base64: string) {
|
| 273 |
+
const normalized = base64.replace(/\s/g, "");
|
| 274 |
+
const padding = normalized.endsWith("==") ? 2 : normalized.endsWith("=") ? 1 : 0;
|
| 275 |
+
const bytes = Math.max(0, Math.floor((normalized.length * 3) / 4) - padding);
|
| 276 |
+
|
| 277 |
+
if (bytes >= 1024 * 1024) {
|
| 278 |
+
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
|
| 279 |
+
}
|
| 280 |
+
if (bytes >= 1024) {
|
| 281 |
+
return `${(bytes / 1024).toFixed(1)} KB`;
|
| 282 |
+
}
|
| 283 |
+
return `${bytes} B`;
|
| 284 |
+
}
|
| 285 |
+
|
| 286 |
+
function formatImageDimensions(width: number, height: number) {
|
| 287 |
+
return `${width} x ${height}`;
|
| 288 |
+
}
|
web/src/app/image/components/image-sidebar.tsx
CHANGED
|
@@ -30,7 +30,7 @@ export function ImageSidebar({
|
|
| 30 |
hideActionButtons = false,
|
| 31 |
}: ImageSidebarProps) {
|
| 32 |
return (
|
| 33 |
-
<aside className="min-h-0">
|
| 34 |
<div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
|
| 35 |
{!hideActionButtons && (
|
| 36 |
<div className="flex items-center gap-2">
|
|
@@ -49,7 +49,7 @@ export function ImageSidebar({
|
|
| 49 |
</div>
|
| 50 |
)}
|
| 51 |
|
| 52 |
-
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1">
|
| 53 |
{isLoadingHistory ? (
|
| 54 |
<div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
|
| 55 |
<LoaderCircle className="size-4 animate-spin" />
|
|
|
|
| 30 |
hideActionButtons = false,
|
| 31 |
}: ImageSidebarProps) {
|
| 32 |
return (
|
| 33 |
+
<aside className="h-full min-h-0 overflow-hidden">
|
| 34 |
<div className="flex h-full min-h-0 flex-col gap-2 py-1 sm:gap-3 sm:py-2">
|
| 35 |
{!hideActionButtons && (
|
| 36 |
<div className="flex items-center gap-2">
|
|
|
|
| 49 |
</div>
|
| 50 |
)}
|
| 51 |
|
| 52 |
+
<div className="min-h-0 flex-1 space-y-2 overflow-y-auto pr-1 [scrollbar-color:rgba(120,113,108,.45)_transparent] [scrollbar-width:thin] [&::-webkit-scrollbar]:w-1.5 [&::-webkit-scrollbar-thumb]:rounded-full [&::-webkit-scrollbar-thumb]:bg-stone-400/45 [&::-webkit-scrollbar-track]:bg-transparent">
|
| 53 |
{isLoadingHistory ? (
|
| 54 |
<div className="flex items-center gap-2 px-2 py-3 text-sm text-stone-500">
|
| 55 |
<LoaderCircle className="size-4 animate-spin" />
|
web/src/app/image/page.tsx
CHANGED
|
@@ -11,6 +11,8 @@ import { ImageLightbox } from "@/components/image-lightbox";
|
|
| 11 |
import {
|
| 12 |
Dialog,
|
| 13 |
DialogContent,
|
|
|
|
|
|
|
| 14 |
DialogHeader,
|
| 15 |
DialogTitle,
|
| 16 |
} from "@/components/ui/dialog";
|
|
@@ -22,6 +24,7 @@ import {
|
|
| 22 |
deleteImageConversation,
|
| 23 |
getImageConversationStats,
|
| 24 |
listImageConversations,
|
|
|
|
| 25 |
saveImageConversations,
|
| 26 |
type ImageConversation,
|
| 27 |
type ImageConversationMode,
|
|
@@ -192,6 +195,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 192 |
const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
|
| 193 |
const [lightboxOpen, setLightboxOpen] = useState(false);
|
| 194 |
const [lightboxIndex, setLightboxIndex] = useState(0);
|
|
|
|
| 195 |
|
| 196 |
const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
|
| 197 |
const selectedConversation = useMemo(
|
|
@@ -206,6 +210,13 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 206 |
}, 0),
|
| 207 |
[conversations],
|
| 208 |
);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 209 |
|
| 210 |
useEffect(() => {
|
| 211 |
conversationsRef.current = conversations;
|
|
@@ -328,7 +339,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 328 |
]);
|
| 329 |
conversationsRef.current = nextConversations;
|
| 330 |
setConversations(nextConversations);
|
| 331 |
-
await
|
| 332 |
};
|
| 333 |
|
| 334 |
const updateConversation = useCallback(
|
|
@@ -346,7 +357,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 346 |
conversationsRef.current = nextConversations;
|
| 347 |
setConversations(nextConversations);
|
| 348 |
if (options.persist !== false) {
|
| 349 |
-
await
|
| 350 |
}
|
| 351 |
},
|
| 352 |
[],
|
|
@@ -407,6 +418,29 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 407 |
}
|
| 408 |
};
|
| 409 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 410 |
const appendReferenceImages = useCallback(async (files: File[]) => {
|
| 411 |
if (files.length === 0) {
|
| 412 |
return;
|
|
@@ -720,7 +754,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 720 |
const draftTurn: ImageTurn = {
|
| 721 |
id: turnId,
|
| 722 |
prompt,
|
| 723 |
-
model: "
|
| 724 |
mode: imageMode,
|
| 725 |
referenceImages: imageMode === "edit" ? referenceImages : [],
|
| 726 |
count: parsedCount,
|
|
@@ -772,9 +806,9 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 772 |
isLoadingHistory={isLoadingHistory}
|
| 773 |
selectedConversationId={selectedConversationId}
|
| 774 |
onCreateDraft={handleCreateDraft}
|
| 775 |
-
onClearHistory={
|
| 776 |
onSelectConversation={setSelectedConversationId}
|
| 777 |
-
onDeleteConversation={
|
| 778 |
formatConversationTime={formatConversationTime}
|
| 779 |
/>
|
| 780 |
</div>
|
|
@@ -796,12 +830,12 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 796 |
handleCreateDraft();
|
| 797 |
setIsHistoryOpen(false);
|
| 798 |
}}
|
| 799 |
-
onClearHistory={
|
| 800 |
onSelectConversation={(id) => {
|
| 801 |
setSelectedConversationId(id);
|
| 802 |
setIsHistoryOpen(false);
|
| 803 |
}}
|
| 804 |
-
onDeleteConversation={
|
| 805 |
formatConversationTime={formatConversationTime}
|
| 806 |
hideActionButtons
|
| 807 |
/>
|
|
@@ -829,7 +863,7 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 829 |
<Button
|
| 830 |
variant="outline"
|
| 831 |
className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
|
| 832 |
-
onClick={
|
| 833 |
disabled={conversations.length === 0}
|
| 834 |
>
|
| 835 |
<Trash2 className="size-4" />
|
|
@@ -877,6 +911,27 @@ function ImagePageContent({ isAdmin }: { isAdmin: boolean }) {
|
|
| 877 |
onOpenChange={setLightboxOpen}
|
| 878 |
onIndexChange={setLightboxIndex}
|
| 879 |
/>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 880 |
</>
|
| 881 |
);
|
| 882 |
}
|
|
|
|
| 11 |
import {
|
| 12 |
Dialog,
|
| 13 |
DialogContent,
|
| 14 |
+
DialogDescription,
|
| 15 |
+
DialogFooter,
|
| 16 |
DialogHeader,
|
| 17 |
DialogTitle,
|
| 18 |
} from "@/components/ui/dialog";
|
|
|
|
| 24 |
deleteImageConversation,
|
| 25 |
getImageConversationStats,
|
| 26 |
listImageConversations,
|
| 27 |
+
saveImageConversation,
|
| 28 |
saveImageConversations,
|
| 29 |
type ImageConversation,
|
| 30 |
type ImageConversationMode,
|
|
|
|
| 195 |
const [lightboxImages, setLightboxImages] = useState<ImageLightboxItem[]>([]);
|
| 196 |
const [lightboxOpen, setLightboxOpen] = useState(false);
|
| 197 |
const [lightboxIndex, setLightboxIndex] = useState(0);
|
| 198 |
+
const [deleteConfirm, setDeleteConfirm] = useState<{ type: "one"; id: string } | { type: "all" } | null>(null);
|
| 199 |
|
| 200 |
const parsedCount = useMemo(() => Math.max(1, Math.min(10, Number(imageCount) || 1)), [imageCount]);
|
| 201 |
const selectedConversation = useMemo(
|
|
|
|
| 210 |
}, 0),
|
| 211 |
[conversations],
|
| 212 |
);
|
| 213 |
+
const deleteConfirmTitle = deleteConfirm?.type === "all" ? "清空历史记录" : deleteConfirm?.type === "one" ? "删除对话" : "";
|
| 214 |
+
const deleteConfirmDescription =
|
| 215 |
+
deleteConfirm?.type === "all"
|
| 216 |
+
? "确认删除全部图片历史记录吗?删除后无法恢复。"
|
| 217 |
+
: deleteConfirm?.type === "one"
|
| 218 |
+
? "确认删除这条图片对话吗?删除后无法恢复。"
|
| 219 |
+
: "";
|
| 220 |
|
| 221 |
useEffect(() => {
|
| 222 |
conversationsRef.current = conversations;
|
|
|
|
| 339 |
]);
|
| 340 |
conversationsRef.current = nextConversations;
|
| 341 |
setConversations(nextConversations);
|
| 342 |
+
await saveImageConversation(conversation);
|
| 343 |
};
|
| 344 |
|
| 345 |
const updateConversation = useCallback(
|
|
|
|
| 357 |
conversationsRef.current = nextConversations;
|
| 358 |
setConversations(nextConversations);
|
| 359 |
if (options.persist !== false) {
|
| 360 |
+
await saveImageConversation(nextConversation);
|
| 361 |
}
|
| 362 |
},
|
| 363 |
[],
|
|
|
|
| 418 |
}
|
| 419 |
};
|
| 420 |
|
| 421 |
+
const openDeleteConversationConfirm = (id: string) => {
|
| 422 |
+
setIsHistoryOpen(false);
|
| 423 |
+
setDeleteConfirm({ type: "one", id });
|
| 424 |
+
};
|
| 425 |
+
|
| 426 |
+
const openClearHistoryConfirm = () => {
|
| 427 |
+
setIsHistoryOpen(false);
|
| 428 |
+
setDeleteConfirm({ type: "all" });
|
| 429 |
+
};
|
| 430 |
+
|
| 431 |
+
const handleConfirmDelete = async () => {
|
| 432 |
+
const target = deleteConfirm;
|
| 433 |
+
setDeleteConfirm(null);
|
| 434 |
+
if (!target) {
|
| 435 |
+
return;
|
| 436 |
+
}
|
| 437 |
+
if (target.type === "all") {
|
| 438 |
+
await handleClearHistory();
|
| 439 |
+
return;
|
| 440 |
+
}
|
| 441 |
+
await handleDeleteConversation(target.id);
|
| 442 |
+
};
|
| 443 |
+
|
| 444 |
const appendReferenceImages = useCallback(async (files: File[]) => {
|
| 445 |
if (files.length === 0) {
|
| 446 |
return;
|
|
|
|
| 754 |
const draftTurn: ImageTurn = {
|
| 755 |
id: turnId,
|
| 756 |
prompt,
|
| 757 |
+
model: "gpt-image-2",
|
| 758 |
mode: imageMode,
|
| 759 |
referenceImages: imageMode === "edit" ? referenceImages : [],
|
| 760 |
count: parsedCount,
|
|
|
|
| 806 |
isLoadingHistory={isLoadingHistory}
|
| 807 |
selectedConversationId={selectedConversationId}
|
| 808 |
onCreateDraft={handleCreateDraft}
|
| 809 |
+
onClearHistory={openClearHistoryConfirm}
|
| 810 |
onSelectConversation={setSelectedConversationId}
|
| 811 |
+
onDeleteConversation={openDeleteConversationConfirm}
|
| 812 |
formatConversationTime={formatConversationTime}
|
| 813 |
/>
|
| 814 |
</div>
|
|
|
|
| 830 |
handleCreateDraft();
|
| 831 |
setIsHistoryOpen(false);
|
| 832 |
}}
|
| 833 |
+
onClearHistory={openClearHistoryConfirm}
|
| 834 |
onSelectConversation={(id) => {
|
| 835 |
setSelectedConversationId(id);
|
| 836 |
setIsHistoryOpen(false);
|
| 837 |
}}
|
| 838 |
+
onDeleteConversation={openDeleteConversationConfirm}
|
| 839 |
formatConversationTime={formatConversationTime}
|
| 840 |
hideActionButtons
|
| 841 |
/>
|
|
|
|
| 863 |
<Button
|
| 864 |
variant="outline"
|
| 865 |
className="h-10 rounded-2xl border-stone-200 bg-white/85 px-3 text-stone-600 shadow-sm"
|
| 866 |
+
onClick={openClearHistoryConfirm}
|
| 867 |
disabled={conversations.length === 0}
|
| 868 |
>
|
| 869 |
<Trash2 className="size-4" />
|
|
|
|
| 911 |
onOpenChange={setLightboxOpen}
|
| 912 |
onIndexChange={setLightboxIndex}
|
| 913 |
/>
|
| 914 |
+
|
| 915 |
+
{deleteConfirm ? (
|
| 916 |
+
<Dialog open onOpenChange={(open) => (!open ? setDeleteConfirm(null) : null)}>
|
| 917 |
+
<DialogContent showCloseButton={false} className="rounded-2xl p-6">
|
| 918 |
+
<DialogHeader className="gap-2">
|
| 919 |
+
<DialogTitle>{deleteConfirmTitle}</DialogTitle>
|
| 920 |
+
<DialogDescription className="text-sm leading-6">
|
| 921 |
+
{deleteConfirmDescription}
|
| 922 |
+
</DialogDescription>
|
| 923 |
+
</DialogHeader>
|
| 924 |
+
<DialogFooter>
|
| 925 |
+
<Button variant="outline" onClick={() => setDeleteConfirm(null)}>
|
| 926 |
+
取消
|
| 927 |
+
</Button>
|
| 928 |
+
<Button className="bg-rose-600 text-white hover:bg-rose-700" onClick={() => void handleConfirmDelete()}>
|
| 929 |
+
确认删除
|
| 930 |
+
</Button>
|
| 931 |
+
</DialogFooter>
|
| 932 |
+
</DialogContent>
|
| 933 |
+
</Dialog>
|
| 934 |
+
) : null}
|
| 935 |
</>
|
| 936 |
);
|
| 937 |
}
|
web/src/app/layout.tsx
CHANGED
|
@@ -22,7 +22,7 @@ export default function RootLayout({
|
|
| 22 |
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
| 23 |
}}
|
| 24 |
>
|
| 25 |
-
<Toaster position="top-center" richColors />
|
| 26 |
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 py-2 text-stone-900 sm:px-6 lg:px-8">
|
| 27 |
<div className="mx-auto flex max-w-[1440px] flex-col gap-5">
|
| 28 |
<TopNav />
|
|
|
|
| 22 |
'"SF Pro Display","SF Pro Text","PingFang SC","Microsoft YaHei","Helvetica Neue",sans-serif',
|
| 23 |
}}
|
| 24 |
>
|
| 25 |
+
<Toaster position="top-center" richColors offset={48} />
|
| 26 |
<main className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(255,255,255,0.92),_rgba(245,239,231,0.96)_42%,_rgba(240,235,227,0.99)_100%)] px-4 py-2 text-stone-900 sm:px-6 lg:px-8">
|
| 27 |
<div className="mx-auto flex max-w-[1440px] flex-col gap-5">
|
| 28 |
<TopNav />
|
web/src/app/logs/page.tsx
CHANGED
|
@@ -53,6 +53,7 @@ function LogsContent() {
|
|
| 53 |
const [startDate, setStartDate] = useState("");
|
| 54 |
const [endDate, setEndDate] = useState("");
|
| 55 |
const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
|
|
|
|
| 56 |
const [lightboxIndex, setLightboxIndex] = useState(0);
|
| 57 |
const [lightboxOpen, setLightboxOpen] = useState(false);
|
| 58 |
const [page, setPage] = useState(1);
|
|
@@ -83,6 +84,11 @@ function LogsContent() {
|
|
| 83 |
setEndDate("");
|
| 84 |
};
|
| 85 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 86 |
useEffect(() => {
|
| 87 |
void loadLogs();
|
| 88 |
}, [type, startDate, endDate]);
|
|
@@ -151,7 +157,7 @@ function LogsContent() {
|
|
| 151 |
) : null}
|
| 152 |
<TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
|
| 153 |
<TableCell>
|
| 154 |
-
<Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() =>
|
| 155 |
查看详情
|
| 156 |
</Button>
|
| 157 |
</TableCell>
|
|
@@ -172,7 +178,7 @@ function LogsContent() {
|
|
| 172 |
{!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
|
| 173 |
</CardContent>
|
| 174 |
</Card>
|
| 175 |
-
<Dialog open={
|
| 176 |
<DialogContent className="w-[min(92vw,920px)] rounded-2xl p-6">
|
| 177 |
<DialogHeader>
|
| 178 |
<DialogTitle>日志详情</DialogTitle>
|
|
|
|
| 53 |
const [startDate, setStartDate] = useState("");
|
| 54 |
const [endDate, setEndDate] = useState("");
|
| 55 |
const [detailLog, setDetailLog] = useState<SystemLog | null>(null);
|
| 56 |
+
const [detailOpen, setDetailOpen] = useState(false);
|
| 57 |
const [lightboxIndex, setLightboxIndex] = useState(0);
|
| 58 |
const [lightboxOpen, setLightboxOpen] = useState(false);
|
| 59 |
const [page, setPage] = useState(1);
|
|
|
|
| 84 |
setEndDate("");
|
| 85 |
};
|
| 86 |
|
| 87 |
+
const openDetail = (item: SystemLog) => {
|
| 88 |
+
setDetailLog(item);
|
| 89 |
+
setDetailOpen(true);
|
| 90 |
+
};
|
| 91 |
+
|
| 92 |
useEffect(() => {
|
| 93 |
void loadLogs();
|
| 94 |
}, [type, startDate, endDate]);
|
|
|
|
| 157 |
) : null}
|
| 158 |
<TableCell className="max-w-[420px] truncate text-stone-500">{item.summary || "-"}</TableCell>
|
| 159 |
<TableCell>
|
| 160 |
+
<Button variant="ghost" className="h-8 rounded-lg px-3 text-stone-600" onClick={() => openDetail(item)}>
|
| 161 |
查看详情
|
| 162 |
</Button>
|
| 163 |
</TableCell>
|
|
|
|
| 178 |
{!isLoading && items.length === 0 ? <div className="px-6 py-14 text-center text-sm text-stone-500">没有找到日志</div> : null}
|
| 179 |
</CardContent>
|
| 180 |
</Card>
|
| 181 |
+
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
| 182 |
<DialogContent className="w-[min(92vw,920px)] rounded-2xl p-6">
|
| 183 |
<DialogHeader>
|
| 184 |
<DialogTitle>日志详情</DialogTitle>
|
web/src/app/settings/components/config-card.tsx
CHANGED
|
@@ -15,12 +15,14 @@ import { useSettingsStore } from "../store";
|
|
| 15 |
export function ConfigCard() {
|
| 16 |
const [isTestingProxy, setIsTestingProxy] = useState(false);
|
| 17 |
const [proxyTestResult, setProxyTestResult] = useState<ProxyTestResult | null>(null);
|
|
|
|
| 18 |
const config = useSettingsStore((state) => state.config);
|
| 19 |
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
| 20 |
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
| 21 |
const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
|
| 22 |
const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
|
| 23 |
const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
|
|
|
|
| 24 |
const setProxy = useSettingsStore((state) => state.setProxy);
|
| 25 |
const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
|
| 26 |
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
|
@@ -140,6 +142,23 @@ export function ConfigCard() {
|
|
| 140 |
/>
|
| 141 |
自动移除异常账号
|
| 142 |
</label>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 143 |
</div>
|
| 144 |
|
| 145 |
<div className="flex justify-end">
|
|
|
|
| 15 |
export function ConfigCard() {
|
| 16 |
const [isTestingProxy, setIsTestingProxy] = useState(false);
|
| 17 |
const [proxyTestResult, setProxyTestResult] = useState<ProxyTestResult | null>(null);
|
| 18 |
+
const logLevelOptions = ["debug", "info", "warning", "error"];
|
| 19 |
const config = useSettingsStore((state) => state.config);
|
| 20 |
const isLoadingConfig = useSettingsStore((state) => state.isLoadingConfig);
|
| 21 |
const isSavingConfig = useSettingsStore((state) => state.isSavingConfig);
|
| 22 |
const setRefreshAccountIntervalMinute = useSettingsStore((state) => state.setRefreshAccountIntervalMinute);
|
| 23 |
const setImageRetentionDays = useSettingsStore((state) => state.setImageRetentionDays);
|
| 24 |
const setAutoRemoveInvalidAccounts = useSettingsStore((state) => state.setAutoRemoveInvalidAccounts);
|
| 25 |
+
const setLogLevel = useSettingsStore((state) => state.setLogLevel);
|
| 26 |
const setProxy = useSettingsStore((state) => state.setProxy);
|
| 27 |
const setBaseUrl = useSettingsStore((state) => state.setBaseUrl);
|
| 28 |
const saveConfig = useSettingsStore((state) => state.saveConfig);
|
|
|
|
| 142 |
/>
|
| 143 |
自动移除异常账号
|
| 144 |
</label>
|
| 145 |
+
<div className="space-y-3 rounded-xl border border-stone-200 bg-white px-4 py-3">
|
| 146 |
+
<div>
|
| 147 |
+
<label className="text-sm text-stone-700">控制台日志级别</label>
|
| 148 |
+
<p className="mt-1 text-xs text-stone-500">不选择时使用默认 info / warning / error。</p>
|
| 149 |
+
</div>
|
| 150 |
+
<div className="grid grid-cols-2 gap-2">
|
| 151 |
+
{logLevelOptions.map((level) => (
|
| 152 |
+
<label key={level} className="flex items-center gap-2 text-sm capitalize text-stone-700">
|
| 153 |
+
<Checkbox
|
| 154 |
+
checked={Boolean(config?.log_levels?.includes(level))}
|
| 155 |
+
onCheckedChange={(checked) => setLogLevel(level, Boolean(checked))}
|
| 156 |
+
/>
|
| 157 |
+
{level}
|
| 158 |
+
</label>
|
| 159 |
+
))}
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
</div>
|
| 163 |
|
| 164 |
<div className="flex justify-end">
|
web/src/app/settings/store.ts
CHANGED
|
@@ -27,6 +27,7 @@ function normalizeConfig(config: SettingsConfig): SettingsConfig {
|
|
| 27 |
refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
|
| 28 |
image_retention_days: Number(config.image_retention_days || 30),
|
| 29 |
auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
|
|
|
|
| 30 |
proxy: typeof config.proxy === "string" ? config.proxy : "",
|
| 31 |
base_url: typeof config.base_url === "string" ? config.base_url : "",
|
| 32 |
};
|
|
@@ -82,6 +83,7 @@ type SettingsStore = {
|
|
| 82 |
setRefreshAccountIntervalMinute: (value: string) => void;
|
| 83 |
setImageRetentionDays: (value: string) => void;
|
| 84 |
setAutoRemoveInvalidAccounts: (value: boolean) => void;
|
|
|
|
| 85 |
setProxy: (value: string) => void;
|
| 86 |
setBaseUrl: (value: string) => void;
|
| 87 |
|
|
@@ -200,6 +202,16 @@ export const useSettingsStore = create<SettingsStore>((set, get) => ({
|
|
| 200 |
set((state) => state.config ? { config: { ...state.config, auto_remove_invalid_accounts: value } } : {});
|
| 201 |
},
|
| 202 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 203 |
setProxy: (value) => {
|
| 204 |
set((state) => {
|
| 205 |
if (!state.config) {
|
|
|
|
| 27 |
refresh_account_interval_minute: Number(config.refresh_account_interval_minute || 5),
|
| 28 |
image_retention_days: Number(config.image_retention_days || 30),
|
| 29 |
auto_remove_invalid_accounts: Boolean(config.auto_remove_invalid_accounts),
|
| 30 |
+
log_levels: Array.isArray(config.log_levels) ? config.log_levels : [],
|
| 31 |
proxy: typeof config.proxy === "string" ? config.proxy : "",
|
| 32 |
base_url: typeof config.base_url === "string" ? config.base_url : "",
|
| 33 |
};
|
|
|
|
| 83 |
setRefreshAccountIntervalMinute: (value: string) => void;
|
| 84 |
setImageRetentionDays: (value: string) => void;
|
| 85 |
setAutoRemoveInvalidAccounts: (value: boolean) => void;
|
| 86 |
+
setLogLevel: (level: string, enabled: boolean) => void;
|
| 87 |
setProxy: (value: string) => void;
|
| 88 |
setBaseUrl: (value: string) => void;
|
| 89 |
|
|
|
|
| 202 |
set((state) => state.config ? { config: { ...state.config, auto_remove_invalid_accounts: value } } : {});
|
| 203 |
},
|
| 204 |
|
| 205 |
+
setLogLevel: (level, enabled) => {
|
| 206 |
+
set((state) => {
|
| 207 |
+
if (!state.config) return {};
|
| 208 |
+
const levels = new Set(state.config.log_levels || []);
|
| 209 |
+
if (enabled) levels.add(level);
|
| 210 |
+
else levels.delete(level);
|
| 211 |
+
return { config: { ...state.config, log_levels: Array.from(levels) } };
|
| 212 |
+
});
|
| 213 |
+
},
|
| 214 |
+
|
| 215 |
setProxy: (value) => {
|
| 216 |
set((state) => {
|
| 217 |
if (!state.config) {
|
web/src/lib/api.ts
CHANGED
|
@@ -2,7 +2,7 @@ import { httpRequest } from "@/lib/request";
|
|
| 2 |
|
| 3 |
export type AccountType = "Free" | "Plus" | "ProLite" | "Pro" | "Team";
|
| 4 |
export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
|
| 5 |
-
export type ImageModel = "
|
| 6 |
export type AuthRole = "admin" | "user";
|
| 7 |
|
| 8 |
export type Account = {
|
|
@@ -56,6 +56,7 @@ export type SettingsConfig = {
|
|
| 56 |
refresh_account_interval_minute?: number | string;
|
| 57 |
image_retention_days?: number | string;
|
| 58 |
auto_remove_invalid_accounts?: boolean;
|
|
|
|
| 59 |
[key: string]: unknown;
|
| 60 |
};
|
| 61 |
|
|
@@ -75,6 +76,11 @@ export type SystemLog = {
|
|
| 75 |
[key: string]: unknown;
|
| 76 |
};
|
| 77 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 78 |
export type LoginResponse = {
|
| 79 |
ok: boolean;
|
| 80 |
version: string;
|
|
@@ -147,7 +153,7 @@ export async function updateAccount(
|
|
| 147 |
}
|
| 148 |
|
| 149 |
export async function generateImage(prompt: string, model?: ImageModel, size?: string) {
|
| 150 |
-
return httpRequest<
|
| 151 |
"/v1/images/generations",
|
| 152 |
{
|
| 153 |
method: "POST",
|
|
@@ -178,7 +184,7 @@ export async function editImage(files: File | File[], prompt: string, model?: Im
|
|
| 178 |
}
|
| 179 |
formData.append("n", "1");
|
| 180 |
|
| 181 |
-
return httpRequest<
|
| 182 |
"/v1/images/edits",
|
| 183 |
{
|
| 184 |
method: "POST",
|
|
|
|
| 2 |
|
| 3 |
export type AccountType = "Free" | "Plus" | "ProLite" | "Pro" | "Team";
|
| 4 |
export type AccountStatus = "正常" | "限流" | "异常" | "禁用";
|
| 5 |
+
export type ImageModel = "gpt-image-2" | "codex-gpt-image-2";
|
| 6 |
export type AuthRole = "admin" | "user";
|
| 7 |
|
| 8 |
export type Account = {
|
|
|
|
| 56 |
refresh_account_interval_minute?: number | string;
|
| 57 |
image_retention_days?: number | string;
|
| 58 |
auto_remove_invalid_accounts?: boolean;
|
| 59 |
+
log_levels?: string[];
|
| 60 |
[key: string]: unknown;
|
| 61 |
};
|
| 62 |
|
|
|
|
| 76 |
[key: string]: unknown;
|
| 77 |
};
|
| 78 |
|
| 79 |
+
export type ImageResponse = {
|
| 80 |
+
created: number;
|
| 81 |
+
data: Array<{ b64_json: string; revised_prompt?: string }>;
|
| 82 |
+
};
|
| 83 |
+
|
| 84 |
export type LoginResponse = {
|
| 85 |
ok: boolean;
|
| 86 |
version: string;
|
|
|
|
| 153 |
}
|
| 154 |
|
| 155 |
export async function generateImage(prompt: string, model?: ImageModel, size?: string) {
|
| 156 |
+
return httpRequest<ImageResponse>(
|
| 157 |
"/v1/images/generations",
|
| 158 |
{
|
| 159 |
method: "POST",
|
|
|
|
| 184 |
}
|
| 185 |
formData.append("n", "1");
|
| 186 |
|
| 187 |
+
return httpRequest<ImageResponse>(
|
| 188 |
"/v1/images/edits",
|
| 189 |
{
|
| 190 |
method: "POST",
|
web/src/lib/request.ts
CHANGED
|
@@ -7,6 +7,27 @@ type RequestConfig = AxiosRequestConfig & {
|
|
| 7 |
redirectOnUnauthorized?: boolean;
|
| 8 |
};
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
const request = axios.create({
|
| 11 |
baseURL: webConfig.apiUrl.replace(/\/$/, ""),
|
| 12 |
});
|
|
@@ -26,7 +47,7 @@ request.interceptors.request.use(async (config) => {
|
|
| 26 |
|
| 27 |
request.interceptors.response.use(
|
| 28 |
(response) => response,
|
| 29 |
-
async (error: AxiosError<
|
| 30 |
const status = error.response?.status;
|
| 31 |
const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
|
| 32 |
if (status === 401 && shouldRedirect && typeof window !== "undefined") {
|
|
@@ -42,8 +63,8 @@ request.interceptors.response.use(
|
|
| 42 |
|
| 43 |
const payload = error.response?.data;
|
| 44 |
const message =
|
| 45 |
-
payload?.detail
|
| 46 |
-
payload?.error ||
|
| 47 |
payload?.message ||
|
| 48 |
error.message ||
|
| 49 |
`请求失败 (${status || 500})`;
|
|
|
|
| 7 |
redirectOnUnauthorized?: boolean;
|
| 8 |
};
|
| 9 |
|
| 10 |
+
type ErrorPayload = {
|
| 11 |
+
detail?: string | { error?: string | { message?: string } };
|
| 12 |
+
error?: string | { message?: string };
|
| 13 |
+
message?: string;
|
| 14 |
+
};
|
| 15 |
+
|
| 16 |
+
function errorMessageFromValue(value: unknown): string {
|
| 17 |
+
if (typeof value === "string") {
|
| 18 |
+
return value;
|
| 19 |
+
}
|
| 20 |
+
if (!value || typeof value !== "object") {
|
| 21 |
+
return "";
|
| 22 |
+
}
|
| 23 |
+
|
| 24 |
+
const item = value as { error?: unknown; message?: unknown };
|
| 25 |
+
if (typeof item.message === "string") {
|
| 26 |
+
return item.message;
|
| 27 |
+
}
|
| 28 |
+
return errorMessageFromValue(item.error);
|
| 29 |
+
}
|
| 30 |
+
|
| 31 |
const request = axios.create({
|
| 32 |
baseURL: webConfig.apiUrl.replace(/\/$/, ""),
|
| 33 |
});
|
|
|
|
| 47 |
|
| 48 |
request.interceptors.response.use(
|
| 49 |
(response) => response,
|
| 50 |
+
async (error: AxiosError<ErrorPayload>) => {
|
| 51 |
const status = error.response?.status;
|
| 52 |
const shouldRedirect = (error.config as RequestConfig | undefined)?.redirectOnUnauthorized !== false;
|
| 53 |
if (status === 401 && shouldRedirect && typeof window !== "undefined") {
|
|
|
|
| 63 |
|
| 64 |
const payload = error.response?.data;
|
| 65 |
const message =
|
| 66 |
+
errorMessageFromValue(payload?.detail) ||
|
| 67 |
+
errorMessageFromValue(payload?.error) ||
|
| 68 |
payload?.message ||
|
| 69 |
error.message ||
|
| 70 |
`请求失败 (${status || 500})`;
|
web/src/store/image-conversations.ts
CHANGED
|
@@ -120,7 +120,7 @@ function normalizeTurn(turn: ImageTurn & Record<string, unknown>): ImageTurn {
|
|
| 120 |
return {
|
| 121 |
id: String(turn.id || `${Date.now()}`),
|
| 122 |
prompt: String(turn.prompt || ""),
|
| 123 |
-
model: (turn.model as ImageModel) || "
|
| 124 |
mode: turn.mode === "edit" ? "edit" : "generate",
|
| 125 |
referenceImages: getLegacyReferenceImages(turn),
|
| 126 |
count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
|
|
@@ -145,7 +145,7 @@ function normalizeConversation(conversation: ImageConversation & Record<string,
|
|
| 145 |
normalizeTurn({
|
| 146 |
id: String(conversation.id || `${Date.now()}`),
|
| 147 |
prompt: String(conversation.prompt || ""),
|
| 148 |
-
model: (conversation.model as ImageModel) || "
|
| 149 |
mode: conversation.mode === "edit" ? "edit" : "generate",
|
| 150 |
referenceImages: getLegacyReferenceImages(conversation),
|
| 151 |
count: Number(conversation.count || 1),
|
|
@@ -174,6 +174,15 @@ function sortImageConversations(conversations: ImageConversation[]): ImageConver
|
|
| 174 |
return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
| 175 |
}
|
| 176 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 177 |
function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> {
|
| 178 |
const result = imageConversationWriteQueue.then(operation);
|
| 179 |
imageConversationWriteQueue = result.then(
|
|
@@ -185,8 +194,9 @@ function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T>
|
|
| 185 |
|
| 186 |
async function readStoredImageConversations(): Promise<ImageConversation[]> {
|
| 187 |
const items =
|
| 188 |
-
(await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(
|
| 189 |
-
|
|
|
|
| 190 |
return items.map(normalizeConversation);
|
| 191 |
}
|
| 192 |
|
|
@@ -196,17 +206,28 @@ export async function listImageConversations(): Promise<ImageConversation[]> {
|
|
| 196 |
|
| 197 |
export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> {
|
| 198 |
await queueImageConversationWrite(async () => {
|
| 199 |
-
const
|
| 200 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 201 |
});
|
| 202 |
}
|
| 203 |
|
| 204 |
export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
|
| 205 |
await queueImageConversationWrite(async () => {
|
| 206 |
const items = await readStoredImageConversations();
|
|
|
|
|
|
|
|
|
|
| 207 |
const nextItems = sortImageConversations([
|
| 208 |
-
|
| 209 |
-
...items.filter((item) => item.id !==
|
| 210 |
]);
|
| 211 |
await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
|
| 212 |
});
|
|
|
|
| 120 |
return {
|
| 121 |
id: String(turn.id || `${Date.now()}`),
|
| 122 |
prompt: String(turn.prompt || ""),
|
| 123 |
+
model: (turn.model as ImageModel) || "gpt-image-2",
|
| 124 |
mode: turn.mode === "edit" ? "edit" : "generate",
|
| 125 |
referenceImages: getLegacyReferenceImages(turn),
|
| 126 |
count: Math.max(1, Number(turn.count || normalizedImages.length || 1)),
|
|
|
|
| 145 |
normalizeTurn({
|
| 146 |
id: String(conversation.id || `${Date.now()}`),
|
| 147 |
prompt: String(conversation.prompt || ""),
|
| 148 |
+
model: (conversation.model as ImageModel) || "gpt-image-2",
|
| 149 |
mode: conversation.mode === "edit" ? "edit" : "generate",
|
| 150 |
referenceImages: getLegacyReferenceImages(conversation),
|
| 151 |
count: Number(conversation.count || 1),
|
|
|
|
| 174 |
return [...conversations].sort((a, b) => b.updatedAt.localeCompare(a.updatedAt));
|
| 175 |
}
|
| 176 |
|
| 177 |
+
function getTimestamp(value: string) {
|
| 178 |
+
const time = new Date(value).getTime();
|
| 179 |
+
return Number.isFinite(time) ? time : 0;
|
| 180 |
+
}
|
| 181 |
+
|
| 182 |
+
function pickLatestConversation(current: ImageConversation, next: ImageConversation) {
|
| 183 |
+
return getTimestamp(next.updatedAt) >= getTimestamp(current.updatedAt) ? next : current;
|
| 184 |
+
}
|
| 185 |
+
|
| 186 |
function queueImageConversationWrite<T>(operation: () => Promise<T>): Promise<T> {
|
| 187 |
const result = imageConversationWriteQueue.then(operation);
|
| 188 |
imageConversationWriteQueue = result.then(
|
|
|
|
| 194 |
|
| 195 |
async function readStoredImageConversations(): Promise<ImageConversation[]> {
|
| 196 |
const items =
|
| 197 |
+
(await imageConversationStorage.getItem<Array<ImageConversation & Record<string, unknown>>>(
|
| 198 |
+
IMAGE_CONVERSATIONS_KEY,
|
| 199 |
+
)) || [];
|
| 200 |
return items.map(normalizeConversation);
|
| 201 |
}
|
| 202 |
|
|
|
|
| 206 |
|
| 207 |
export async function saveImageConversations(conversations: ImageConversation[]): Promise<void> {
|
| 208 |
await queueImageConversationWrite(async () => {
|
| 209 |
+
const items = await readStoredImageConversations();
|
| 210 |
+
const conversationMap = new Map(items.map((item) => [item.id, item]));
|
| 211 |
+
for (const conversation of conversations.map(normalizeConversation)) {
|
| 212 |
+
const current = conversationMap.get(conversation.id);
|
| 213 |
+
conversationMap.set(conversation.id, current ? pickLatestConversation(current, conversation) : conversation);
|
| 214 |
+
}
|
| 215 |
+
await imageConversationStorage.setItem(
|
| 216 |
+
IMAGE_CONVERSATIONS_KEY,
|
| 217 |
+
sortImageConversations([...conversationMap.values()]),
|
| 218 |
+
);
|
| 219 |
});
|
| 220 |
}
|
| 221 |
|
| 222 |
export async function saveImageConversation(conversation: ImageConversation): Promise<void> {
|
| 223 |
await queueImageConversationWrite(async () => {
|
| 224 |
const items = await readStoredImageConversations();
|
| 225 |
+
const nextConversation = normalizeConversation(conversation);
|
| 226 |
+
const current = items.find((item) => item.id === nextConversation.id);
|
| 227 |
+
const persistedConversation = current ? pickLatestConversation(current, nextConversation) : nextConversation;
|
| 228 |
const nextItems = sortImageConversations([
|
| 229 |
+
persistedConversation,
|
| 230 |
+
...items.filter((item) => item.id !== persistedConversation.id),
|
| 231 |
]);
|
| 232 |
await imageConversationStorage.setItem(IMAGE_CONVERSATIONS_KEY, nextItems);
|
| 233 |
});
|