dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
package/server/pool.py
ADDED
|
@@ -0,0 +1,1335 @@
|
|
|
1
|
+
"""本地版执行层:出图 / 文案统一走 OpenAI 兼容接口(号池或任意第三方均可)。
|
|
2
|
+
|
|
3
|
+
工作台不绑定任何特定号池:接口地址与密钥完全由 `config.local.json` 的
|
|
4
|
+
`api_base_url` / `api_key` 决定,指向本插件内置的 38651 号池、用户已有的
|
|
5
|
+
ChatGPT2API 号池(如 38650)、或任意 OpenAI 兼容服务皆可。PoolManager 的
|
|
6
|
+
公开接口、job 状态机、result 数据契约保持稳定,因此 app.py / history.py /
|
|
7
|
+
imaging.py / 前端代码无需随底层接口变化而改动,历史、单张重做、压缩图、
|
|
8
|
+
批量下载全部沿用。
|
|
9
|
+
|
|
10
|
+
VPS 原件备份在 pool.py.vps_original。
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import asyncio
|
|
15
|
+
import base64
|
|
16
|
+
import io
|
|
17
|
+
import json
|
|
18
|
+
import os
|
|
19
|
+
import re
|
|
20
|
+
import threading
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
from urllib.parse import urlparse
|
|
25
|
+
|
|
26
|
+
import httpx
|
|
27
|
+
from PIL import Image
|
|
28
|
+
|
|
29
|
+
BASE = Path(__file__).resolve().parent
|
|
30
|
+
OUT_DIR = BASE / "images" # 默认保存目录(未配置 output_dir 时用它)
|
|
31
|
+
CONFIG_FILE = BASE / "config.local.json"
|
|
32
|
+
LOG_FILE = BASE / "pool.log"
|
|
33
|
+
# 未完成任务落盘:后端重启/崩溃后把队列捞回来,页面刷新也不会白丢图
|
|
34
|
+
STATE_FILE = BASE / "workbench" / "pending_jobs.json"
|
|
35
|
+
DEFAULT_POOL_DIR = Path(os.environ.get(
|
|
36
|
+
"DSH_IMAGE_POOL_DIR", str(Path.home() / ".dsh" / "chatgpt2api-image")))
|
|
37
|
+
|
|
38
|
+
# 号池 config.json 里 image_account_concurrency=3,本地 worker 与它对齐,
|
|
39
|
+
# 再多也不会更快,只会让上游排队。
|
|
40
|
+
WORKERS = max(1, min(3, int(os.environ.get("WORKBENCH_WORKERS", "3") or 3)))
|
|
41
|
+
IMAGE_TIMEOUT = 300 # 实测单张 45~62 秒,留足余量
|
|
42
|
+
TEXT_TIMEOUT = 180
|
|
43
|
+
# 失败不中断任务:重新排队就是换号(号池按 round-robin 取号),最多换 3 轮。
|
|
44
|
+
# 每轮之间留出时间让号池把刚失败的号标掉,避免立刻又撞同一个号。
|
|
45
|
+
RETRY_MAX = 3
|
|
46
|
+
RETRY_DELAYS = [15, 40, 90]
|
|
47
|
+
|
|
48
|
+
# 失败归因:号池每次调用都会写一条 /api/logs(detail.account_email 就是那次用的号),
|
|
49
|
+
# 成功响应里的 _account_email 被它主动 pop 掉了,所以只能回读日志来对号。
|
|
50
|
+
CALL_LOG_TTL = 3.0 # 多个 worker 同时失败时共用一次日志请求
|
|
51
|
+
CALL_LOG_WINDOW = 1800.0 # 只认半小时内的调用记录,旧记录不参与匹配
|
|
52
|
+
|
|
53
|
+
# 账号失败账本:哪个号今天失败过几次,界面上如实显示
|
|
54
|
+
FAIL_LEDGER = BASE / "workbench" / "account_fails.json"
|
|
55
|
+
FAIL_KEEP_DAYS = 14
|
|
56
|
+
|
|
57
|
+
RATIO_SIZE = {
|
|
58
|
+
"3:4": "1024x1536", "2:3": "1024x1536", "9:16": "1024x1536",
|
|
59
|
+
"1:1": "1024x1024",
|
|
60
|
+
"4:3": "1536x1024", "3:2": "1536x1024", "16:9": "1536x1024",
|
|
61
|
+
# 公众号首图 2.35:1 上游没有原生尺寸,先按最宽的 3:2 出,
|
|
62
|
+
# 再由 imaging.crop_to 居中裁成 1536x654(比例精确、分辨率够发微信)
|
|
63
|
+
"2.35:1": "1536x1024",
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def mask(s) -> str:
|
|
68
|
+
s = re.sub(r"[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+",
|
|
69
|
+
lambda m: m.group(0)[:2] + "***@***", str(s))
|
|
70
|
+
return re.sub(r"sk-[A-Za-z0-9_\-]{12,}", "sk-***", s)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def mask_email(s) -> str:
|
|
74
|
+
"""掩码但保留可辨识度:号池的邮箱前缀是随机串,只留 2 位很容易两个号撞成一样,
|
|
75
|
+
真出事时反而看不出是谁。留前 4 位 + 域名。"""
|
|
76
|
+
s = str(s or "").strip()
|
|
77
|
+
if "@" not in s:
|
|
78
|
+
return (s[:3] + "***") if s else "—"
|
|
79
|
+
local, domain = s.split("@", 1)
|
|
80
|
+
return (local[:4] if len(local) > 4 else local[:2]) + "***@" + domain
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
# ---------- 账号失败账本 ----------
|
|
84
|
+
_LEDGER_LOCK = threading.Lock()
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _ledger_load() -> dict:
|
|
88
|
+
try:
|
|
89
|
+
data = json.loads(FAIL_LEDGER.read_text(encoding="utf-8"))
|
|
90
|
+
if isinstance(data, dict):
|
|
91
|
+
data.setdefault("days", {})
|
|
92
|
+
data.setdefault("last", {})
|
|
93
|
+
return data
|
|
94
|
+
except Exception:
|
|
95
|
+
pass
|
|
96
|
+
return {"days": {}, "last": {}}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _ledger_save(data: dict) -> None:
|
|
100
|
+
"""原子写:账本是给用户看的凭据,不能因为进程被中断留下半个文件。"""
|
|
101
|
+
try:
|
|
102
|
+
FAIL_LEDGER.parent.mkdir(parents=True, exist_ok=True)
|
|
103
|
+
tmp = FAIL_LEDGER.with_suffix(".tmp")
|
|
104
|
+
tmp.write_text(json.dumps(data, ensure_ascii=False, indent=1), encoding="utf-8")
|
|
105
|
+
os.replace(tmp, FAIL_LEDGER)
|
|
106
|
+
except Exception as e:
|
|
107
|
+
log(f"失败账本写入失败:{mask(e)[:120]}")
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def record_account_fail(email: str, err: str) -> int:
|
|
111
|
+
"""记一次失败:某某账号在某天失败 1 次。返回该号今天累计次数。"""
|
|
112
|
+
key = str(email or "").strip().lower() or "unknown"
|
|
113
|
+
day = time.strftime("%Y-%m-%d")
|
|
114
|
+
with _LEDGER_LOCK:
|
|
115
|
+
data = _ledger_load()
|
|
116
|
+
days = data["days"]
|
|
117
|
+
bucket = days.setdefault(day, {})
|
|
118
|
+
bucket[key] = int(bucket.get(key) or 0) + 1
|
|
119
|
+
data["last"][key] = {"error": mask(err)[:200], "at": int(time.time())}
|
|
120
|
+
# 只留最近两周,别让账本无限长
|
|
121
|
+
cutoff = time.strftime("%Y-%m-%d", time.localtime(time.time() - FAIL_KEEP_DAYS * 86400))
|
|
122
|
+
for d in [d for d in days if d < cutoff]:
|
|
123
|
+
days.pop(d, None)
|
|
124
|
+
_ledger_save(data)
|
|
125
|
+
return bucket[key]
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def fails_today() -> dict:
|
|
129
|
+
"""今天各账号的失败次数:{email: n}(email 为明文,展示时再掩码)。"""
|
|
130
|
+
with _LEDGER_LOCK:
|
|
131
|
+
data = _ledger_load()
|
|
132
|
+
return dict(data.get("days", {}).get(time.strftime("%Y-%m-%d"), {}))
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def fails_summary() -> list[dict]:
|
|
136
|
+
"""给监控面板用:按失败次数倒序,带最后一次的错误。"""
|
|
137
|
+
today = fails_today()
|
|
138
|
+
with _LEDGER_LOCK:
|
|
139
|
+
data = _ledger_load()
|
|
140
|
+
last = data.get("last") or {}
|
|
141
|
+
rows = []
|
|
142
|
+
for email, n in today.items():
|
|
143
|
+
info = last.get(email) or {}
|
|
144
|
+
rows.append({"account": mask_email(email) if "@" in email else "未知账号",
|
|
145
|
+
"account_email": email, "count": int(n),
|
|
146
|
+
"error": str(info.get("error") or ""), "at": int(info.get("at") or 0)})
|
|
147
|
+
rows.sort(key=lambda x: (-x["count"], -x["at"]))
|
|
148
|
+
return rows
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
# 号池账号 status(正常/限流/禁用/异常)-> 工作台监控用的状态键
|
|
152
|
+
STATUS_MAP = {"正常": "ready", "限流": "limited", "禁用": "unhealthy", "异常": "error"}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def map_status(raw) -> str:
|
|
156
|
+
return STATUS_MAP.get(str(raw or "").strip(), "error")
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
# 本机内置号池:端口 → DSH 里的目录名。换接口时账号明细要跟着新接口走,
|
|
160
|
+
# 不能还拿上一个号池的 admin-key 去问新号池(会 401,界面就留着旧账号不放)。
|
|
161
|
+
LOCAL_POOL_PORTS = {"38650": "chatgpt2api-native", "38651": "chatgpt2api-image"}
|
|
162
|
+
|
|
163
|
+
|
|
164
|
+
def _read_admin_key(d: Path) -> str:
|
|
165
|
+
try:
|
|
166
|
+
return (d / "admin-key").read_text(encoding="utf-8").strip()
|
|
167
|
+
except Exception:
|
|
168
|
+
return ""
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def _dsh_home() -> Path:
|
|
172
|
+
return Path(os.environ.get("DSH_HOME") or (Path.home() / ".dsh"))
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def pool_home(cfg: dict | None = None) -> Path:
|
|
176
|
+
"""当前接口对应的号池数据目录(admin-key / user-api-keys.json 都在里面)。
|
|
177
|
+
|
|
178
|
+
先按 Base URL 的端口映射到 ~/.dsh 下的号池目录,对不上再退回配置里的 pool_dir。
|
|
179
|
+
本机同时开着两个号池(38650 写作、38651 生图),切换接口后必须跟着换目录,
|
|
180
|
+
否则会拿错 admin-key 和用户 Key——以前就是这么串的。
|
|
181
|
+
"""
|
|
182
|
+
cfg = cfg or load_config()
|
|
183
|
+
base = str(cfg.get("api_base_url") or cfg.get("pool_base_url") or "")
|
|
184
|
+
try:
|
|
185
|
+
port = str(urlparse(base).port or "")
|
|
186
|
+
except Exception:
|
|
187
|
+
port = ""
|
|
188
|
+
name = LOCAL_POOL_PORTS.get(port)
|
|
189
|
+
if name:
|
|
190
|
+
d = _dsh_home() / name
|
|
191
|
+
if (d / "admin-key").exists() or (d / "user-api-keys.json").exists():
|
|
192
|
+
return d
|
|
193
|
+
return Path(str(cfg.get("pool_dir") or DEFAULT_POOL_DIR))
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def pool_admin_key(cfg: dict | None = None) -> str:
|
|
197
|
+
"""拉真实账号列表用的号池 admin key(/api/accounts),绝不随便给浏览器。
|
|
198
|
+
|
|
199
|
+
先看当前接口对应的号池目录,没有 admin-key 再退回配置里的 pool_dir:
|
|
200
|
+
以前只认 pool_dir,把 Base URL 从 38651 换成 38650 之后仍在用 38651 的
|
|
201
|
+
admin-key,/api/accounts 直接 401,界面只好继续显示上一个号池的账号。
|
|
202
|
+
"""
|
|
203
|
+
cfg = cfg or load_config()
|
|
204
|
+
key = _read_admin_key(pool_home(cfg))
|
|
205
|
+
if key:
|
|
206
|
+
return key
|
|
207
|
+
return _read_admin_key(Path(str(cfg.get("pool_dir") or DEFAULT_POOL_DIR)))
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _parse_dt(s) -> float:
|
|
211
|
+
if not s or str(s).strip() in ("None", "null", ""):
|
|
212
|
+
return 0.0
|
|
213
|
+
s = str(s).strip().replace("Z", "").replace("T", " ")
|
|
214
|
+
m = re.match(r"^(.*?)([+-]\d{2}:?\d{2})?\s*$", s)
|
|
215
|
+
base = (m.group(1) if m else s).strip()
|
|
216
|
+
for fmt in ("%Y-%m-%d %H:%M:%S", "%Y-%m-%d %H:%M:%S.%f", "%Y-%m-%d %H:%M"):
|
|
217
|
+
try:
|
|
218
|
+
return datetime.strptime(base, fmt).timestamp()
|
|
219
|
+
except Exception:
|
|
220
|
+
continue
|
|
221
|
+
return 0.0
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def log(msg: str) -> None:
|
|
225
|
+
line = f"[{time.strftime('%H:%M:%S')}] {mask(msg)}"
|
|
226
|
+
print(line, flush=True)
|
|
227
|
+
try:
|
|
228
|
+
with LOG_FILE.open("a", encoding="utf-8") as f:
|
|
229
|
+
f.write(line + "\n")
|
|
230
|
+
except Exception:
|
|
231
|
+
pass
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# 版本段:v1 / v2 / v1beta / v1beta1 都算,结尾有它就不再补 /v1
|
|
235
|
+
_VER_TAIL = re.compile(r"/v\d+[a-z0-9]*$", re.I)
|
|
236
|
+
# 用户直接粘完整端点时顺手剥掉,只留根
|
|
237
|
+
_ENDPOINT_TAIL = re.compile(
|
|
238
|
+
r"/(images/generations|images/edits|images/variations|chat/completions|completions|models)$", re.I)
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def norm_base_url(raw: str) -> str:
|
|
242
|
+
"""把 Base URL 归一化成「带版本段」的形式。
|
|
243
|
+
|
|
244
|
+
界面上显示的必须是 http://127.0.0.1:38651/v1 这种可读地址:
|
|
245
|
+
大多数人是从 OpenAI 文档照抄的,少写 /v1 会让人怀疑是不是填错了。
|
|
246
|
+
请求侧本来就统一发 /v1/... 绝对路径,所以带不带都能通,这里只是不让它看起来像错的。
|
|
247
|
+
"""
|
|
248
|
+
b = str(raw or "").strip().rstrip("/")
|
|
249
|
+
if not b:
|
|
250
|
+
return b
|
|
251
|
+
if not re.match(r"^https?://", b, re.I):
|
|
252
|
+
# 本机默认 http,外部域名默认 https,省得用户少打协议头就报连不上
|
|
253
|
+
local = re.match(r"^(127\.0\.0\.1|localhost|\[::1\])(:|\/|$)", b, re.I)
|
|
254
|
+
b = ("http://" if local else "https://") + b
|
|
255
|
+
# 粘了整条端点(.../v1/images/generations)时截到版本段为止
|
|
256
|
+
m = _ENDPOINT_TAIL.search(b)
|
|
257
|
+
if m:
|
|
258
|
+
b = b[:m.start()].rstrip("/")
|
|
259
|
+
if not _VER_TAIL.search(urlparse(b).path.rstrip("/")):
|
|
260
|
+
b = b + "/v1"
|
|
261
|
+
return b
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def out_dir(cfg: dict | None = None) -> Path:
|
|
265
|
+
"""当前生效的图片保存目录。
|
|
266
|
+
|
|
267
|
+
config.local.json 的 output_dir 可写绝对路径,也可写相对路径(相对工作台目录);
|
|
268
|
+
留空就用默认的 images/。目录不可用时退回默认目录,绝不让存图这一步把出图弄挂。
|
|
269
|
+
"""
|
|
270
|
+
cfg = cfg if cfg is not None else load_config()
|
|
271
|
+
raw = str(cfg.get("output_dir") or "").strip()
|
|
272
|
+
if not raw:
|
|
273
|
+
return OUT_DIR
|
|
274
|
+
p = Path(os.path.expandvars(os.path.expanduser(raw)))
|
|
275
|
+
if not p.is_absolute():
|
|
276
|
+
p = BASE / p
|
|
277
|
+
try:
|
|
278
|
+
p.mkdir(parents=True, exist_ok=True)
|
|
279
|
+
if not os.access(p, os.W_OK):
|
|
280
|
+
raise OSError("目录不可写")
|
|
281
|
+
return p
|
|
282
|
+
except Exception as e:
|
|
283
|
+
log(f"保存目录 {p} 不可用({e}),本次退回 {OUT_DIR}")
|
|
284
|
+
return OUT_DIR
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
def image_dirs() -> list[Path]:
|
|
288
|
+
"""找图时依次尝试的目录:当前目录 → 用过的旧目录 → 默认目录。
|
|
289
|
+
|
|
290
|
+
用户把保存目录从 D:\\a 换到 D:\\b 之后,D:\\a 里那些图在历史里还得能打开,
|
|
291
|
+
否则一改设置,之前的作品全变成破图。
|
|
292
|
+
"""
|
|
293
|
+
cfg = load_config()
|
|
294
|
+
dirs = [out_dir(cfg)]
|
|
295
|
+
for s in reversed(cfg.get("output_dirs_seen") or []):
|
|
296
|
+
try:
|
|
297
|
+
p = Path(str(s))
|
|
298
|
+
except Exception:
|
|
299
|
+
continue
|
|
300
|
+
if p.is_dir() and p not in dirs:
|
|
301
|
+
dirs.append(p)
|
|
302
|
+
if OUT_DIR not in dirs:
|
|
303
|
+
dirs.append(OUT_DIR)
|
|
304
|
+
return dirs
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
def image_path(name: str) -> Path | None:
|
|
308
|
+
"""按文件名找图:当前保存目录优先,其次历史目录,最后默认目录。"""
|
|
309
|
+
name = str(name or "").strip()
|
|
310
|
+
if not name or "/" in name or "\\" in name:
|
|
311
|
+
return None
|
|
312
|
+
for d in image_dirs():
|
|
313
|
+
p = d / name
|
|
314
|
+
if p.is_file():
|
|
315
|
+
return p
|
|
316
|
+
return None
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def load_config() -> dict:
|
|
320
|
+
cfg = {
|
|
321
|
+
# 旧字段(保留兼容):pool_base_url / pool_dir / model_image / model_text
|
|
322
|
+
"pool_base_url": "http://127.0.0.1:38651/v1",
|
|
323
|
+
"pool_dir": str(DEFAULT_POOL_DIR),
|
|
324
|
+
"model_image": "gpt-image-2",
|
|
325
|
+
"model_text": "auto",
|
|
326
|
+
# 生成图片保存目录:绝对路径,或相对工作台目录的路径;空 = 默认 images/
|
|
327
|
+
"output_dir": "",
|
|
328
|
+
# 工作台显示名(页头标题 / 浏览器标签);空 = 用内置默认名
|
|
329
|
+
"app_name": "",
|
|
330
|
+
# —— 通用接口(Provider)扩展字段 ——
|
|
331
|
+
"provider_name": "本地生图号池",
|
|
332
|
+
"supports_edits": True, # 是否支持参考图(/v1/images/edits)
|
|
333
|
+
"edits_style": "auto", # auto | json | multipart | off
|
|
334
|
+
"image_response_format": "auto", # auto | b64_json | url
|
|
335
|
+
"monitor_mode": "auto", # auto | pool | single
|
|
336
|
+
}
|
|
337
|
+
if CONFIG_FILE.exists():
|
|
338
|
+
try:
|
|
339
|
+
cfg.update(json.loads(CONFIG_FILE.read_text(encoding="utf-8")))
|
|
340
|
+
except Exception as e:
|
|
341
|
+
log(f"config.local.json 读取失败: {e}")
|
|
342
|
+
return cfg
|
|
343
|
+
|
|
344
|
+
|
|
345
|
+
def _read_pool_user_key(cfg: dict) -> str:
|
|
346
|
+
"""从号池目录里的 user-api-keys.json 读第一个 sk- 用户 Key(仅号池模式用)。"""
|
|
347
|
+
p = pool_home(cfg) / "user-api-keys.json"
|
|
348
|
+
try:
|
|
349
|
+
d = json.loads(p.read_text(encoding="utf-8"))
|
|
350
|
+
for v in (d.values() if isinstance(d, dict) else d):
|
|
351
|
+
if isinstance(v, str) and v.strip().startswith("sk-"):
|
|
352
|
+
return v.strip()
|
|
353
|
+
except Exception as e:
|
|
354
|
+
log(f"号池用户 Key 读取失败: {e}")
|
|
355
|
+
return ""
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def pool_api_key(cfg: dict | None = None) -> str:
|
|
359
|
+
"""兼容旧名。用户 Key 只在服务端读取,绝不返回给浏览器。"""
|
|
360
|
+
cfg = cfg or load_config()
|
|
361
|
+
key = str(cfg.get("api_key") or cfg.get("pool_api_key") or "").strip()
|
|
362
|
+
return key or _read_pool_user_key(cfg)
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def provider_cfg(cfg: dict | None = None) -> dict:
|
|
366
|
+
"""把配置归一化成通用 Provider 视图(兼容旧字段)。
|
|
367
|
+
|
|
368
|
+
这样无论接的是本地号池,还是任意 OpenAI 兼容接口,执行层只认这一份结构。
|
|
369
|
+
"""
|
|
370
|
+
cfg = cfg or load_config()
|
|
371
|
+
base = norm_base_url(str(cfg.get("api_base_url") or cfg.get("pool_base_url") or ""))
|
|
372
|
+
admin = str(cfg.get("pool_dir") or DEFAULT_POOL_DIR)
|
|
373
|
+
netloc = urlparse(base).netloc if base else ""
|
|
374
|
+
is_pool = (netloc.startswith("127.0.0.1:") or netloc.startswith("localhost:")
|
|
375
|
+
or str(cfg.get("monitor_mode") or "") == "pool")
|
|
376
|
+
return {
|
|
377
|
+
"name": str(cfg.get("provider_name") or "生图接口"),
|
|
378
|
+
"base_url": base,
|
|
379
|
+
# 版本段之前的根(http://host:port 或带前置路径的那种),号池管理端点用
|
|
380
|
+
"api_root": _VER_TAIL.sub("", base.rstrip("/")) or base,
|
|
381
|
+
"api_key": pool_api_key(cfg),
|
|
382
|
+
"model_image": str(cfg.get("model_image") or "gpt-image-2"),
|
|
383
|
+
"model_text": str(cfg.get("model_text") or "auto"),
|
|
384
|
+
"supports_edits": bool(cfg.get("supports_edits", True)),
|
|
385
|
+
"edits_style": str(cfg.get("edits_style") or "auto"),
|
|
386
|
+
"image_format": str(cfg.get("image_response_format") or "auto"),
|
|
387
|
+
"monitor_mode": str(cfg.get("monitor_mode") or "auto"),
|
|
388
|
+
"pool_dir": admin,
|
|
389
|
+
"output_dir": str(out_dir(cfg)),
|
|
390
|
+
"is_pool": is_pool,
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
|
|
394
|
+
def api_url(p: dict, path: str) -> str:
|
|
395
|
+
"""拼接 /v1 下的接口地址。base_url 已经带版本段,所以只拼后半截。
|
|
396
|
+
|
|
397
|
+
不能用 httpx 的 base_url 自动合并:base 带路径时它是「追加」而不是「替换」,
|
|
398
|
+
base=http://h/v1 + /v1/models 会变成 http://h/v1/v1/models,直接 404。
|
|
399
|
+
"""
|
|
400
|
+
return str(p["base_url"]).rstrip("/") + "/" + path.lstrip("/")
|
|
401
|
+
|
|
402
|
+
|
|
403
|
+
def root_url(p: dict, path: str) -> str:
|
|
404
|
+
"""拼接版本段之外的管理端点(/health、/api/accounts)。"""
|
|
405
|
+
return str(p.get("api_root") or p["base_url"]).rstrip("/") + "/" + path.lstrip("/")
|
|
406
|
+
|
|
407
|
+
|
|
408
|
+
def size_for_ratio(ratio) -> str | None:
|
|
409
|
+
return RATIO_SIZE.get(str(ratio or "").strip())
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
def _json_of(r: httpx.Response) -> dict:
|
|
413
|
+
try:
|
|
414
|
+
j = r.json()
|
|
415
|
+
return j if isinstance(j, dict) else {}
|
|
416
|
+
except Exception:
|
|
417
|
+
return {}
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
def _err_text(j: dict) -> str:
|
|
421
|
+
e = j.get("error") or j.get("detail") or j
|
|
422
|
+
if isinstance(e, dict):
|
|
423
|
+
return str(e.get("message") or e.get("error") or e)
|
|
424
|
+
return str(e)
|
|
425
|
+
|
|
426
|
+
|
|
427
|
+
def raise_for_response(r: httpx.Response) -> None:
|
|
428
|
+
"""把接口的各种失败翻译成用户能看懂、能自己动手解决的提示。"""
|
|
429
|
+
j = _json_of(r)
|
|
430
|
+
msg = _err_text(j)[:300]
|
|
431
|
+
low = msg.lower()
|
|
432
|
+
if r.status_code == 401:
|
|
433
|
+
raise RuntimeError("接口 API Key 无效或未配置,请打开「接口设置」检查密钥")
|
|
434
|
+
if r.status_code == 403:
|
|
435
|
+
raise RuntimeError("接口拒绝访问(403):密钥无权限或该模型未开通")
|
|
436
|
+
if r.status_code == 404:
|
|
437
|
+
raise RuntimeError(
|
|
438
|
+
"接口地址不存在(404):确认 Base URL 是否以 /v1 结尾、路径是否为 OpenAI 兼容接口")
|
|
439
|
+
if r.status_code == 429 or "no available image quota" in low or "quota" in low and "exceed" in low:
|
|
440
|
+
raise RuntimeError("接口额度已用完或触发限流,等配额恢复或换一个接口 / 密钥")
|
|
441
|
+
if r.status_code == 502 or "proxy" in low or "failed to connect" in low or "timed out" in low or "socks" in low:
|
|
442
|
+
raise RuntimeError("接口不可达:确认 Base URL、本机网络或出口代理(号池需能访问 OpenAI / ChatGPT)")
|
|
443
|
+
if "unsupported image model" in low or "model_not_found" in low or "does not exist" in low:
|
|
444
|
+
raise RuntimeError(f"接口不支持该出图模型:{msg[:120]},请在「接口设置」里换一个模型名")
|
|
445
|
+
if "content_policy_violation" in low or "text reply" in low:
|
|
446
|
+
raise RuntimeError("上游没返回图片(只回了文字),把画面描述写得更具体后重试")
|
|
447
|
+
raise RuntimeError(f"接口 HTTP {r.status_code}: {msg}")
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
async def test_connection(overrides: dict | None = None) -> dict:
|
|
451
|
+
"""按给定(或已保存)配置试连一次,返回 {ok,status,message,models}。
|
|
452
|
+
|
|
453
|
+
Base URL 会被归一化成带 /v1 的形式,请求地址按 base_url + 尾段拼,带不带 /v1 都能通。
|
|
454
|
+
"""
|
|
455
|
+
cfg = load_config()
|
|
456
|
+
if overrides:
|
|
457
|
+
# 前端提交的字段名是 base_url,存量配置里叫 api_base_url / pool_base_url
|
|
458
|
+
if overrides.get("base_url"):
|
|
459
|
+
cfg["api_base_url"] = overrides["base_url"]
|
|
460
|
+
for k in ("api_base_url", "pool_base_url", "api_key", "pool_api_key",
|
|
461
|
+
"model_image", "model_text", "supports_edits", "edits_style",
|
|
462
|
+
"image_response_format", "provider_name", "monitor_mode"):
|
|
463
|
+
if k in overrides and overrides[k] not in (None, ""):
|
|
464
|
+
cfg[k] = overrides[k]
|
|
465
|
+
p = provider_cfg(cfg)
|
|
466
|
+
if not p["base_url"]:
|
|
467
|
+
return {"ok": False, "status": 0, "message": "未填写 Base URL", "models": []}
|
|
468
|
+
hdr = {"Authorization": "Bearer " + p["api_key"]} if p["api_key"] else {}
|
|
469
|
+
try:
|
|
470
|
+
async with httpx.AsyncClient(trust_env=False, timeout=10) as c:
|
|
471
|
+
r = await c.get(api_url(p, "models"), headers=hdr)
|
|
472
|
+
if r.status_code == 200:
|
|
473
|
+
data = _json_of(r).get("data") or []
|
|
474
|
+
models = [str(m.get("id")) for m in data
|
|
475
|
+
if isinstance(m, dict) and m.get("id")][:60]
|
|
476
|
+
return {"ok": True, "status": 200,
|
|
477
|
+
"message": f"连接成功,发现 {len(models)} 个模型",
|
|
478
|
+
"models": models}
|
|
479
|
+
if r.status_code in (401, 403):
|
|
480
|
+
return {"ok": False, "status": r.status_code,
|
|
481
|
+
"message": "密钥无效或无权限(401/403),请检查 API Key", "models": []}
|
|
482
|
+
if r.status_code == 404:
|
|
483
|
+
return {"ok": False, "status": 404,
|
|
484
|
+
"message": "地址可达但没有 /v1/models:确认 Base URL 是否为 OpenAI 兼容根(可带或不带 /v1)",
|
|
485
|
+
"models": []}
|
|
486
|
+
return {"ok": False, "status": r.status_code,
|
|
487
|
+
"message": f"接口返回 HTTP {r.status_code}:{_err_text(_json_of(r))[:160]}",
|
|
488
|
+
"models": []}
|
|
489
|
+
except Exception as e:
|
|
490
|
+
return {"ok": False, "status": 0,
|
|
491
|
+
"message": f"连接失败:{mask(e)[:160]}", "models": []}
|
|
492
|
+
|
|
493
|
+
|
|
494
|
+
class PoolManager:
|
|
495
|
+
"""保持线上版接口:accounts / jobs / running / on_finish / check_state /
|
|
496
|
+
start / stop / submit / snapshot / refresh_env_ids / reset_statuses / check_all。
|
|
497
|
+
"""
|
|
498
|
+
|
|
499
|
+
def __init__(self) -> None:
|
|
500
|
+
# 真实账号列表:来自号池 /api/accounts(admin key),数量 = 号池实际账号数。
|
|
501
|
+
# 选号与严格轮转由号池内部负责;这里只把真实账号铺进监控,不再用占位单号。
|
|
502
|
+
self.accounts: list[dict] = []
|
|
503
|
+
# 工作台侧 Job 记账用的端点占位(不进监控列表,仅用于 running/历史归属)
|
|
504
|
+
self.endpoint: dict = {
|
|
505
|
+
"id": 0, "masked": "生图接口", "env_name": "api",
|
|
506
|
+
"env_id": 0, "status": "ready", "last_used": 0.0, "last_ok": 0.0,
|
|
507
|
+
"last_error_at": 0.0, "uses": 0, "fails": 0, "error": "",
|
|
508
|
+
"busy_since": 0.0, "cooldown": 0, "check_ok": None, "check_msg": "", "check_at": 0.0,
|
|
509
|
+
}
|
|
510
|
+
self._rr_index = 0
|
|
511
|
+
self.jobs: dict[str, dict] = {}
|
|
512
|
+
self.queue: asyncio.Queue[str] = asyncio.Queue()
|
|
513
|
+
self.running: dict[str, dict] = {}
|
|
514
|
+
self._workers: list[asyncio.Task] = []
|
|
515
|
+
# 正在执行的单个任务:手动停止时只取消它,不杀 worker,队列才能继续接新活
|
|
516
|
+
self._job_tasks: dict[str, asyncio.Task] = {}
|
|
517
|
+
self._lock = asyncio.Lock()
|
|
518
|
+
self._check_task: asyncio.Task | None = None
|
|
519
|
+
self._acct_task: asyncio.Task | None = None
|
|
520
|
+
self._acct_cache_at: float = 0.0
|
|
521
|
+
# 当前账号列表是为哪个接口拉的:接口一换,旧列表立刻作废,别让它接着显示
|
|
522
|
+
self._prov_sig: tuple | None = None
|
|
523
|
+
# 账号列表的「上一次长什么样」:只在变化时写日志,空转时同一份内容不重复记
|
|
524
|
+
self._acct_sig: tuple | None = None
|
|
525
|
+
self._acct_beat_at: float = 0.0
|
|
526
|
+
# 上次接口探测时间:离线状态下要能自动重探,否则一次失败会把「离线」钉死
|
|
527
|
+
self._probe_at: float = 0.0
|
|
528
|
+
# 号池调用日志缓存:失败时按 prompt + 起始时间回查,定位这次是哪个号干的
|
|
529
|
+
self._log_cache: tuple[float, list[dict]] = (0.0, [])
|
|
530
|
+
self.check_state: dict = {"running": False, "total": 1, "done": 0,
|
|
531
|
+
"started": 0, "finished": 0, "items": {}}
|
|
532
|
+
self.pool_stat: dict = {"online": False, "total": 0, "active": 0,
|
|
533
|
+
"limited": 0, "quota_total": 0, "message": "尚未探测",
|
|
534
|
+
"mode": "single", "name": "生图接口"}
|
|
535
|
+
self.on_finish = None
|
|
536
|
+
|
|
537
|
+
# ---------- lifecycle ----------
|
|
538
|
+
async def start(self) -> None:
|
|
539
|
+
await self.refresh_env_ids()
|
|
540
|
+
await self.refresh_accounts(force=True)
|
|
541
|
+
self._restore()
|
|
542
|
+
for i in range(WORKERS):
|
|
543
|
+
self._workers.append(asyncio.create_task(self._worker(i + 1)))
|
|
544
|
+
self._acct_task = asyncio.create_task(self._account_refresh_loop())
|
|
545
|
+
p = provider_cfg()
|
|
546
|
+
log(f"本地执行层启动:{WORKERS} 个 worker,接口 {p['base_url']}"
|
|
547
|
+
f"({p['name']}),已同步 {len(self.accounts)} 个账号")
|
|
548
|
+
|
|
549
|
+
# ---------- 任务落盘 / 恢复 ----------
|
|
550
|
+
def _persist(self) -> None:
|
|
551
|
+
"""把「还没结束」的任务写进磁盘。写完原子替换,进程被杀也不会写出半个文件。"""
|
|
552
|
+
try:
|
|
553
|
+
live = [j for j in self.jobs.values()
|
|
554
|
+
if j.get("status") not in ("done", "error", "cancelled")]
|
|
555
|
+
STATE_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
556
|
+
tmp = STATE_FILE.with_suffix(".json.tmp")
|
|
557
|
+
tmp.write_text(json.dumps(live, ensure_ascii=False), encoding="utf-8")
|
|
558
|
+
tmp.replace(STATE_FILE)
|
|
559
|
+
except Exception as e:
|
|
560
|
+
log(f"任务状态落盘失败:{mask(e)[:120]}")
|
|
561
|
+
|
|
562
|
+
def _restore(self) -> int:
|
|
563
|
+
"""启动时把上次没跑完的任务重新入队。图片已出的任务不会再存盘,所以不会重出。"""
|
|
564
|
+
if not STATE_FILE.exists():
|
|
565
|
+
return 0
|
|
566
|
+
try:
|
|
567
|
+
rows = json.loads(STATE_FILE.read_text(encoding="utf-8"))
|
|
568
|
+
except Exception as e:
|
|
569
|
+
log(f"未完成任务读取失败:{mask(e)[:120]}")
|
|
570
|
+
return 0
|
|
571
|
+
n = 0
|
|
572
|
+
for job in rows if isinstance(rows, list) else []:
|
|
573
|
+
if not isinstance(job, dict) or not job.get("id") or job["id"] in self.jobs:
|
|
574
|
+
continue
|
|
575
|
+
job["status"] = "queued"
|
|
576
|
+
job["auto_retry"] = int(job.get("auto_retry") or 0)
|
|
577
|
+
job.pop("retry_at", None)
|
|
578
|
+
job["restored"] = 1
|
|
579
|
+
self.jobs[job["id"]] = job
|
|
580
|
+
self.queue.put_nowait(job["id"])
|
|
581
|
+
n += 1
|
|
582
|
+
if n:
|
|
583
|
+
log(f"重启恢复:{n} 个未完成任务重新入队")
|
|
584
|
+
return n
|
|
585
|
+
|
|
586
|
+
async def stop(self) -> None:
|
|
587
|
+
for t in self._workers:
|
|
588
|
+
t.cancel()
|
|
589
|
+
if self._check_task:
|
|
590
|
+
self._check_task.cancel()
|
|
591
|
+
if self._acct_task:
|
|
592
|
+
self._acct_task.cancel()
|
|
593
|
+
|
|
594
|
+
# ---------- 号池状态 ----------
|
|
595
|
+
async def refresh_env_ids(self) -> None:
|
|
596
|
+
"""探测当前接口。
|
|
597
|
+
|
|
598
|
+
1) 先试号池专有 /health?format=json(含 accounts 汇总)→ mode=pool,可展开账号明细;
|
|
599
|
+
2) 失败则降级为通用接口探测(/v1/models)→ mode=single,在线但没有账号明细。
|
|
600
|
+
这样换成任意第三方兼容接口都不会因「没有 /health」被误判为离线。
|
|
601
|
+
"""
|
|
602
|
+
p = provider_cfg()
|
|
603
|
+
st = {"online": False, "total": 0, "active": 0, "limited": 0,
|
|
604
|
+
"quota_total": 0, "status": "", "message": "尚未探测",
|
|
605
|
+
"mode": "single", "name": p["name"]}
|
|
606
|
+
# --- 1) 号池专有探测 ---
|
|
607
|
+
if p["monitor_mode"] != "single":
|
|
608
|
+
try:
|
|
609
|
+
async with httpx.AsyncClient(trust_env=False, timeout=8) as c:
|
|
610
|
+
r = await c.get(root_url(p, "health?format=json"))
|
|
611
|
+
j = _json_of(r) if r.status_code == 200 else {}
|
|
612
|
+
a = j.get("accounts") or {}
|
|
613
|
+
total = int(a.get("total") or 0)
|
|
614
|
+
if total > 0:
|
|
615
|
+
st.update({
|
|
616
|
+
"mode": "pool",
|
|
617
|
+
"total": total,
|
|
618
|
+
"active": int(a.get("active") or 0),
|
|
619
|
+
"limited": int(a.get("limited") or 0),
|
|
620
|
+
"quota_total": int(a.get("total_quota") or 0),
|
|
621
|
+
"status": str(j.get("status") or ""),
|
|
622
|
+
"online": True,
|
|
623
|
+
})
|
|
624
|
+
st["message"] = f"{total} 个账号 / 配额 {st['quota_total']}"
|
|
625
|
+
self.pool_stat = st
|
|
626
|
+
self._probe_at = time.time() # 命中号池分支也要记,否则每次轮询都重探
|
|
627
|
+
return
|
|
628
|
+
except Exception:
|
|
629
|
+
pass
|
|
630
|
+
# --- 2) 通用接口探测 ---
|
|
631
|
+
try:
|
|
632
|
+
hdr = {"Authorization": "Bearer " + p["api_key"]} if p["api_key"] else {}
|
|
633
|
+
async with httpx.AsyncClient(trust_env=False, timeout=8) as c:
|
|
634
|
+
r = await c.get(api_url(p, "models"), headers=hdr)
|
|
635
|
+
ok = r.status_code < 500
|
|
636
|
+
st.update({
|
|
637
|
+
"mode": "single",
|
|
638
|
+
"online": ok,
|
|
639
|
+
"total": 1,
|
|
640
|
+
"active": 1 if ok else 0,
|
|
641
|
+
"message": (f"通用接口可用 · {p['name']}" if ok
|
|
642
|
+
else f"接口返回 HTTP {r.status_code} · {p['name']}"),
|
|
643
|
+
})
|
|
644
|
+
except Exception as e:
|
|
645
|
+
st.update({"mode": "single", "online": False,
|
|
646
|
+
"message": f"接口不可达:{mask(e)[:120]}"})
|
|
647
|
+
self.pool_stat = st
|
|
648
|
+
self._probe_at = time.time()
|
|
649
|
+
# 自动重探会反复失败,同一条错误只记一次,别把日志刷满
|
|
650
|
+
if not st["online"] and st["message"] != getattr(self, "_probe_msg", ""):
|
|
651
|
+
log(f"接口探测失败:{st['message']}")
|
|
652
|
+
self._probe_msg = "" if st["online"] else st["message"]
|
|
653
|
+
|
|
654
|
+
async def ensure_online(self, min_interval: float = 15.0,
|
|
655
|
+
recheck_interval: float = 60.0) -> None:
|
|
656
|
+
"""离线时自动重探(节流),在线时也隔一分钟对一次账。
|
|
657
|
+
|
|
658
|
+
号池是真异步起的:工作台后端先就绪、号池还在加载账号库,启动那一次探测
|
|
659
|
+
必然失败,然后 pool_stat.online 就一直钉在 False,界面永远显示离线,
|
|
660
|
+
得手动点「刷新状态」才好。所以只要当前是离线态,就隔一段时间自己再探一次。
|
|
661
|
+
|
|
662
|
+
在线也不能一直不探:以前上线之后直接 return,号池增减账号时 /health 报的
|
|
663
|
+
数字永远停在第一次成功的那一刻(导入 33 个号之后仍显示 19 个账号)。
|
|
664
|
+
"""
|
|
665
|
+
online = bool(self.pool_stat.get("online"))
|
|
666
|
+
gap = recheck_interval if online else min_interval
|
|
667
|
+
if time.time() - self._probe_at < gap:
|
|
668
|
+
return
|
|
669
|
+
await self.refresh_env_ids()
|
|
670
|
+
|
|
671
|
+
def _drop_accounts(self) -> None:
|
|
672
|
+
self.accounts = []
|
|
673
|
+
self._acct_sig = None
|
|
674
|
+
|
|
675
|
+
def _accounts_unavailable(self, msg: str) -> None:
|
|
676
|
+
"""账号明细拿不到时的收尾。
|
|
677
|
+
|
|
678
|
+
只有「旧列表条数还和号池自报的总数对得上」才敢继续用,否则一律清空:
|
|
679
|
+
换接口后 admin-key 常对不上(401),以前这种情况会沿用旧列表,监控里就一直
|
|
680
|
+
挂着上一个号池的账号,右上角也照旧显示 32/33。
|
|
681
|
+
"""
|
|
682
|
+
total = int(self.pool_stat.get("total") or 0)
|
|
683
|
+
if self.accounts and len(self.accounts) == total:
|
|
684
|
+
self.pool_stat.pop("accounts_error", None)
|
|
685
|
+
log(msg + ",沿用上次账号列表")
|
|
686
|
+
return
|
|
687
|
+
self._drop_accounts()
|
|
688
|
+
self.pool_stat["accounts_error"] = msg
|
|
689
|
+
log(msg + ",已清空账号列表")
|
|
690
|
+
|
|
691
|
+
async def refresh_accounts(self, force: bool = False) -> None:
|
|
692
|
+
"""从号池 /api/accounts 拉真实账号;非号池接口没有此端点时静默跳过。"""
|
|
693
|
+
now = time.time()
|
|
694
|
+
p = provider_cfg()
|
|
695
|
+
sig = (p["base_url"], p["monitor_mode"], str(p["pool_dir"]))
|
|
696
|
+
if sig != self._prov_sig:
|
|
697
|
+
# 换了接口(地址 / 监控模式 / 号池目录任一变化):上一个接口的账号一个都不留
|
|
698
|
+
self._prov_sig = sig
|
|
699
|
+
self._drop_accounts()
|
|
700
|
+
self.pool_stat.pop("accounts_error", None)
|
|
701
|
+
self._acct_cache_at = 0
|
|
702
|
+
if not force and self.accounts and now - self._acct_cache_at < 20:
|
|
703
|
+
return
|
|
704
|
+
if p["monitor_mode"] == "single":
|
|
705
|
+
self._drop_accounts()
|
|
706
|
+
self._acct_cache_at = now
|
|
707
|
+
return
|
|
708
|
+
admin = pool_admin_key()
|
|
709
|
+
if not admin:
|
|
710
|
+
self._accounts_unavailable("号池模式下没找到 admin-key,账号明细不可用")
|
|
711
|
+
self._acct_cache_at = now
|
|
712
|
+
return
|
|
713
|
+
try:
|
|
714
|
+
async with httpx.AsyncClient(trust_env=False, timeout=15) as c:
|
|
715
|
+
r = await c.get(root_url(p, "api/accounts"),
|
|
716
|
+
headers={"Authorization": "Bearer " + admin})
|
|
717
|
+
if r.status_code != 200:
|
|
718
|
+
# 通用接口没有 /api/accounts 属正常;但号池模式下 401 多半是 admin-key
|
|
719
|
+
# 和当前接口对不上,这时必须清空,否则旧号池的账号会一直挂在监控里。
|
|
720
|
+
self._accounts_unavailable(f"账号明细拉取失败(HTTP {r.status_code})")
|
|
721
|
+
self._acct_cache_at = now
|
|
722
|
+
return
|
|
723
|
+
items = (_json_of(r).get("items") or [])
|
|
724
|
+
except Exception as e:
|
|
725
|
+
self._accounts_unavailable(f"账号明细拉取失败:{mask(e)[:80]}")
|
|
726
|
+
self._acct_cache_at = now
|
|
727
|
+
return
|
|
728
|
+
self.pool_stat.pop("accounts_error", None)
|
|
729
|
+
accounts = []
|
|
730
|
+
for idx, a in enumerate(items, start=1):
|
|
731
|
+
st = map_status(a.get("status"))
|
|
732
|
+
last_used = _parse_dt(a.get("last_used_at"))
|
|
733
|
+
# image_inflight 是号池里「这个号正在出几张图」,唯一可信的占用信号
|
|
734
|
+
try:
|
|
735
|
+
inflight = int(a.get("image_inflight") or 0)
|
|
736
|
+
except (TypeError, ValueError):
|
|
737
|
+
inflight = 0
|
|
738
|
+
if inflight > 0 and st == "ready":
|
|
739
|
+
st = "busy"
|
|
740
|
+
cooldown = 0
|
|
741
|
+
if st == "limited":
|
|
742
|
+
ts = _parse_dt(a.get("restore_at"))
|
|
743
|
+
cd = int(ts - time.time())
|
|
744
|
+
cooldown = cd if cd > 0 else 0
|
|
745
|
+
note = a.get("last_refresh_error") or ""
|
|
746
|
+
if st == "limited":
|
|
747
|
+
note = note or "限流中"
|
|
748
|
+
elif st == "busy":
|
|
749
|
+
note = f"出图中 ×{inflight}"
|
|
750
|
+
accounts.append({
|
|
751
|
+
"id": idx,
|
|
752
|
+
"env_id": idx,
|
|
753
|
+
"env_name": "cpa-image-pool",
|
|
754
|
+
"masked": mask_email(a.get("email")),
|
|
755
|
+
"label": mask_email(a.get("email")),
|
|
756
|
+
"status": st,
|
|
757
|
+
"uses": int(a.get("success") or 0),
|
|
758
|
+
"fails": int(a.get("fail") or 0),
|
|
759
|
+
"hist_done": int(a.get("success") or 0),
|
|
760
|
+
"hist_error": int(a.get("fail") or 0),
|
|
761
|
+
"hist_last_ok": int(last_used or 0),
|
|
762
|
+
"last_used": last_used,
|
|
763
|
+
"last_ok": last_used,
|
|
764
|
+
"cooldown": cooldown,
|
|
765
|
+
"error": note or "",
|
|
766
|
+
"busy_since": (last_used or now) if st == "busy" else 0.0,
|
|
767
|
+
"inflight": inflight,
|
|
768
|
+
"check_ok": None,
|
|
769
|
+
"check_msg": note or "",
|
|
770
|
+
"check_at": 0,
|
|
771
|
+
"quota": int(a.get("quota") or 0),
|
|
772
|
+
})
|
|
773
|
+
self.accounts = accounts
|
|
774
|
+
self._acct_cache_at = now
|
|
775
|
+
# 账号明细就是真相。号池刚导入/删号时它自己的 /health 还是旧的(health 由号池
|
|
776
|
+
# 内部定时重算),页头「号池 X/Y 可用」和监控里的配额会跟着错一截。
|
|
777
|
+
if self.pool_stat.get("mode") == "pool":
|
|
778
|
+
self.pool_stat.update({
|
|
779
|
+
"total": len(accounts),
|
|
780
|
+
"active": sum(1 for x in accounts if x["status"] in ("ready", "busy")),
|
|
781
|
+
"limited": sum(1 for x in accounts if x["status"] == "limited"),
|
|
782
|
+
"quota_total": sum(int(x.get("quota") or 0) for x in accounts),
|
|
783
|
+
})
|
|
784
|
+
self.pool_stat["message"] = (f"{len(accounts)} 个账号 / "
|
|
785
|
+
f"配额 {self.pool_stat['quota_total']}")
|
|
786
|
+
sig = (len(accounts),
|
|
787
|
+
sum(1 for x in accounts if x["status"] == "ready"),
|
|
788
|
+
sum(1 for x in accounts if x["status"] == "limited"),
|
|
789
|
+
sum(1 for x in accounts if x["status"] in ("error", "unhealthy")),
|
|
790
|
+
sum(int(x.get("quota") or 0) for x in accounts))
|
|
791
|
+
# 号池的内存列表只在真的出图 / 手动刷新时才会变,空转期间拉到的永远是同一份。
|
|
792
|
+
# 以前每次拉都写一行「账号列表已同步」,一天能刷出四千多行噪声日志;
|
|
793
|
+
# 现在只在状态真的变了才记,另外每半小时留一条心跳,方便确认同步没死。
|
|
794
|
+
if sig != self._acct_sig or now - self._acct_beat_at >= 1800:
|
|
795
|
+
self._acct_sig = sig
|
|
796
|
+
self._acct_beat_at = now
|
|
797
|
+
log(f"账号列表已同步:{sig[0]} 个(可用 {sig[1]} / 限流 {sig[2]} / 异常 {sig[3]},"
|
|
798
|
+
f"配图额度 {sig[4]})")
|
|
799
|
+
|
|
800
|
+
async def _call_logs(self) -> list[dict]:
|
|
801
|
+
"""读号池最近的调用日志。3 秒缓存,三个 worker 同时失败也只打一次。
|
|
802
|
+
|
|
803
|
+
只对本地号池生效:通用第三方接口没有这个端点,失败就记「未知账号」,
|
|
804
|
+
不影响出图本身。
|
|
805
|
+
"""
|
|
806
|
+
now = time.time()
|
|
807
|
+
ts, items = self._log_cache
|
|
808
|
+
if now - ts < CALL_LOG_TTL:
|
|
809
|
+
return items
|
|
810
|
+
p = provider_cfg()
|
|
811
|
+
admin = pool_admin_key()
|
|
812
|
+
if not admin or not p["is_pool"]:
|
|
813
|
+
self._log_cache = (now, [])
|
|
814
|
+
return []
|
|
815
|
+
got: list[dict] = []
|
|
816
|
+
try:
|
|
817
|
+
async with httpx.AsyncClient(trust_env=False, timeout=6) as c:
|
|
818
|
+
# type=call 只要调用日志:号池每 5 分钟会把 30 多个账号的刷新记录也写进去,
|
|
819
|
+
# 不过滤的话 200 条窗口只够覆盖二十几分钟
|
|
820
|
+
r = await c.get(root_url(p, "api/logs") + "?type=call",
|
|
821
|
+
headers={"Authorization": "Bearer " + admin})
|
|
822
|
+
if r.status_code == 200:
|
|
823
|
+
got = [i for i in (_json_of(r).get("items") or [])
|
|
824
|
+
if str(i.get("type") or "") == "call"]
|
|
825
|
+
except Exception:
|
|
826
|
+
got = []
|
|
827
|
+
self._log_cache = (now, got)
|
|
828
|
+
return got
|
|
829
|
+
|
|
830
|
+
async def _attribute_call(self, job: dict, since: float) -> str:
|
|
831
|
+
"""这次调用落到哪个号上:按 prompt 片段 + 起始时间在号池日志里对上号。
|
|
832
|
+
|
|
833
|
+
号池是内部轮转选号,请求里指定不了账号,所以只能事后回查。
|
|
834
|
+
对不上就返回空串,界面上显示「未知账号」,绝不编造。
|
|
835
|
+
"""
|
|
836
|
+
logs = await self._call_logs()
|
|
837
|
+
if not logs:
|
|
838
|
+
return ""
|
|
839
|
+
head = re.sub(r"\s+", " ", str(job.get("prompt") or "")).strip()[:48]
|
|
840
|
+
if not head:
|
|
841
|
+
# 没有指纹就无从对号:宁可显示「未知账号」,也不能随便认一个坑
|
|
842
|
+
return ""
|
|
843
|
+
floor, ceil = since - 5, time.time() + 120
|
|
844
|
+
# 出图看 /v1/images,文案 / 润色看 /v1/chat,别把另一条链路的日志混进来
|
|
845
|
+
want = "/v1/images" if str(job.get("mode") or "image") == "image" else "/v1/chat"
|
|
846
|
+
best_at, best_email = 0.0, ""
|
|
847
|
+
for it in logs:
|
|
848
|
+
det = it.get("detail") or {}
|
|
849
|
+
if want not in str(det.get("endpoint") or ""):
|
|
850
|
+
continue
|
|
851
|
+
if head:
|
|
852
|
+
body = re.sub(r"\s+", " ", str(det.get("request_text") or ""))
|
|
853
|
+
if head not in body:
|
|
854
|
+
continue
|
|
855
|
+
at = _parse_dt(det.get("started_at"))
|
|
856
|
+
# 日志时间只到秒,且 clock 有微差;给 ±5 秒容差,同时排除半小时前的旧调用
|
|
857
|
+
if at and (at < floor or at > ceil):
|
|
858
|
+
continue
|
|
859
|
+
if at and at < time.time() - CALL_LOG_WINDOW:
|
|
860
|
+
continue
|
|
861
|
+
if at >= best_at:
|
|
862
|
+
best_at = at
|
|
863
|
+
best_email = str(det.get("account_email") or "")
|
|
864
|
+
return best_email
|
|
865
|
+
|
|
866
|
+
def is_busy(self) -> bool:
|
|
867
|
+
"""现在有没有生图请求:有图在跑,或者还有排队的。
|
|
868
|
+
|
|
869
|
+
账号状态只在这时候才会变,监控和后台同步据此决定刷多快。
|
|
870
|
+
"""
|
|
871
|
+
return bool(self.running) or self.queue.qsize() > 0
|
|
872
|
+
|
|
873
|
+
async def refresh_accounts_throttled(self, busy_interval: float = 3.0,
|
|
874
|
+
idle_interval: float = 20.0) -> None:
|
|
875
|
+
"""监控页轮询:出图中才高频,空闲时放慢。
|
|
876
|
+
|
|
877
|
+
以前固定 2 秒一次,监控开着的时候等于每秒半次去打号池;而号池空闲时
|
|
878
|
+
那份列表根本不会变,纯属空转。现在没有生图请求就按 idle_interval 走。
|
|
879
|
+
"""
|
|
880
|
+
gap = busy_interval if self.is_busy() else idle_interval
|
|
881
|
+
if time.time() - self._acct_cache_at >= gap:
|
|
882
|
+
await self.ensure_online()
|
|
883
|
+
await self.refresh_accounts(force=True)
|
|
884
|
+
|
|
885
|
+
async def _account_refresh_loop(self) -> None:
|
|
886
|
+
"""后台同步账号状态:出图时 10 秒一轮,空转时 5 分钟一轮就够。"""
|
|
887
|
+
try:
|
|
888
|
+
while True:
|
|
889
|
+
await asyncio.sleep(10 if self.is_busy() else 300)
|
|
890
|
+
await self.ensure_online(60.0)
|
|
891
|
+
await self.refresh_accounts(force=True)
|
|
892
|
+
except asyncio.CancelledError:
|
|
893
|
+
raise
|
|
894
|
+
|
|
895
|
+
def reset_statuses(self) -> None:
|
|
896
|
+
self.endpoint["fails"] = 0
|
|
897
|
+
self.endpoint["error"] = ""
|
|
898
|
+
if self.endpoint["status"] in ("error", "unhealthy"):
|
|
899
|
+
self.endpoint["status"] = "ready"
|
|
900
|
+
|
|
901
|
+
async def check_all(self) -> dict:
|
|
902
|
+
if self._check_task and not self._check_task.done():
|
|
903
|
+
return self.check_state
|
|
904
|
+
self.check_state = {"running": True, "total": 1, "done": 0,
|
|
905
|
+
"started": int(time.time()), "finished": 0, "items": {}}
|
|
906
|
+
self._check_task = asyncio.create_task(self._check_runner())
|
|
907
|
+
return self.check_state
|
|
908
|
+
|
|
909
|
+
async def _check_runner(self) -> None:
|
|
910
|
+
try:
|
|
911
|
+
await self.refresh_env_ids()
|
|
912
|
+
p = provider_cfg()
|
|
913
|
+
key = p["api_key"]
|
|
914
|
+
ok, msg = False, "未配置接口 API Key"
|
|
915
|
+
if key:
|
|
916
|
+
try:
|
|
917
|
+
async with httpx.AsyncClient(trust_env=False, timeout=15) as c:
|
|
918
|
+
r = await c.get(api_url(p, "models"),
|
|
919
|
+
headers={"Authorization": "Bearer " + key})
|
|
920
|
+
ok = r.status_code == 200
|
|
921
|
+
msg = "正常" if ok else f"HTTP {r.status_code}"
|
|
922
|
+
except Exception as e:
|
|
923
|
+
msg = mask(e)[:120]
|
|
924
|
+
if self.pool_stat.get("mode") == "pool":
|
|
925
|
+
detail = (f"{msg} · {self.pool_stat.get('total')} 个号 / "
|
|
926
|
+
f"配额 {self.pool_stat.get('quota_total')}")
|
|
927
|
+
else:
|
|
928
|
+
detail = f"{msg} · {p['base_url']}"
|
|
929
|
+
self.check_state["items"][p["name"]] = {"ok": ok, "msg": detail}
|
|
930
|
+
self.check_state["done"] = 1
|
|
931
|
+
self.endpoint.update({"check_ok": ok, "check_msg": detail,
|
|
932
|
+
"check_at": time.time()})
|
|
933
|
+
finally:
|
|
934
|
+
self.check_state["running"] = False
|
|
935
|
+
self.check_state["finished"] = int(time.time())
|
|
936
|
+
|
|
937
|
+
# ---------- public ops ----------
|
|
938
|
+
def _single_endpoint(self) -> dict:
|
|
939
|
+
"""通用接口模式下的单端点伪账号,供监控表展示(无账号明细)。"""
|
|
940
|
+
p = provider_cfg()
|
|
941
|
+
online = bool(self.pool_stat.get("online"))
|
|
942
|
+
ep = self.endpoint
|
|
943
|
+
return {
|
|
944
|
+
"id": 1, "masked": p["name"], "label": p["name"],
|
|
945
|
+
"env_name": "api", "env_id": 0,
|
|
946
|
+
"status": "ready" if online else "error",
|
|
947
|
+
"last_used": int(ep.get("last_used") or 0),
|
|
948
|
+
"uses": int(ep.get("uses") or 0), "fails": int(ep.get("fails") or 0),
|
|
949
|
+
"error": "" if online else str(self.pool_stat.get("message") or ""),
|
|
950
|
+
"last_ok": int(ep.get("last_ok") or 0), "busy_since": int(ep.get("busy_since") or 0),
|
|
951
|
+
"cooldown": 0,
|
|
952
|
+
"check_ok": ep.get("check_ok"), "check_msg": str(ep.get("check_msg") or ""),
|
|
953
|
+
"check_at": int(ep.get("check_at") or 0),
|
|
954
|
+
"hist_done": int(ep.get("uses") or 0), "hist_error": int(ep.get("fails") or 0),
|
|
955
|
+
"hist_last_ok": int(ep.get("last_ok") or 0), "quota": None,
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
def _snapshot_accounts(self) -> list[dict]:
|
|
959
|
+
mode = self.pool_stat.get("mode") or ("pool" if self.accounts else "single")
|
|
960
|
+
if mode == "single":
|
|
961
|
+
return [self._single_endpoint()]
|
|
962
|
+
return [
|
|
963
|
+
{
|
|
964
|
+
"id": a["id"], "masked": a["masked"], "label": a["label"],
|
|
965
|
+
"env_name": a["env_name"], "env_id": a["env_id"], "status": a["status"],
|
|
966
|
+
"last_used": int(a["last_used"]), "uses": a["uses"], "fails": a["fails"],
|
|
967
|
+
"error": a["error"], "last_ok": int(a["last_ok"]),
|
|
968
|
+
"busy_since": int(a["busy_since"]), "cooldown": a["cooldown"],
|
|
969
|
+
"check_ok": a["check_ok"], "check_msg": a["check_msg"],
|
|
970
|
+
"check_at": int(a["check_at"]), "hist_done": a["hist_done"],
|
|
971
|
+
"hist_error": a["hist_error"], "hist_last_ok": int(a["hist_last_ok"]),
|
|
972
|
+
"quota": a.get("quota", 0),
|
|
973
|
+
"inflight": a.get("inflight", 0),
|
|
974
|
+
}
|
|
975
|
+
for a in self.accounts
|
|
976
|
+
]
|
|
977
|
+
|
|
978
|
+
def snapshot(self) -> dict:
|
|
979
|
+
now = time.time()
|
|
980
|
+
accounts = self._snapshot_accounts()
|
|
981
|
+
# 限流号不能再算「可用」:它已经不参与轮转,算进去会让人以为 33 个号都能出图
|
|
982
|
+
ready_n = sum(1 for a in accounts if a["status"] == "ready")
|
|
983
|
+
limited_n = sum(1 for a in accounts if a["status"] == "limited")
|
|
984
|
+
# 号池自己选号,工作台只知道「哪几个号 inflight>0」。数量对得上时按序对应,
|
|
985
|
+
# 对不上就统一显示号池里正在出图的号,避免张冠李戴。
|
|
986
|
+
busy_masks = [a["masked"] for a in accounts if a["status"] == "busy"]
|
|
987
|
+
running = []
|
|
988
|
+
for i, (k, v) in enumerate(self.running.items()):
|
|
989
|
+
acc_name = v.get("account") or ""
|
|
990
|
+
if busy_masks and len(busy_masks) == len(self.running):
|
|
991
|
+
acc_name = busy_masks[i]
|
|
992
|
+
elif busy_masks:
|
|
993
|
+
acc_name = "号池 · " + "、".join(busy_masks[:2])
|
|
994
|
+
running.append({"job_id": k, "account": acc_name, "env_id": v.get("env_id"),
|
|
995
|
+
"started": v.get("started", 0),
|
|
996
|
+
"elapsed": int(now - v.get("started", now))})
|
|
997
|
+
current = running[0]["account"] if running else ""
|
|
998
|
+
return {
|
|
999
|
+
"total": len(accounts),
|
|
1000
|
+
"ready": ready_n,
|
|
1001
|
+
"limited": limited_n,
|
|
1002
|
+
"bound": len(accounts),
|
|
1003
|
+
"busy": len(self.running),
|
|
1004
|
+
# 「现在有活干」:正在出图或还有排队。别和 pool_stat 里的 active(可用账号数)搞混
|
|
1005
|
+
"running_now": self.is_busy(),
|
|
1006
|
+
"workers": WORKERS,
|
|
1007
|
+
"queued": self.queue.qsize(),
|
|
1008
|
+
"running": running,
|
|
1009
|
+
"current": current,
|
|
1010
|
+
"rr_index": self._rr_index,
|
|
1011
|
+
"rr_env": self.endpoint["env_name"],
|
|
1012
|
+
"rr_env_id": self.endpoint["env_id"],
|
|
1013
|
+
"accounts": accounts,
|
|
1014
|
+
# 今天哪个号失败过几次:失败会自动换号重试,但账要如实记着
|
|
1015
|
+
"fails_today": sum(int(v) for v in fails_today().values()),
|
|
1016
|
+
"fail_accounts": fails_summary()[:8],
|
|
1017
|
+
# 账号明细拉不到时给前端的原话(换接口后 admin-key 对不上就会走到这里)
|
|
1018
|
+
"accounts_error": str(self.pool_stat.get("accounts_error") or ""),
|
|
1019
|
+
# 真实号池 / 通用接口探测结果,插件面板和 /api/pool 据此展示
|
|
1020
|
+
"pool": dict(self.pool_stat),
|
|
1021
|
+
}
|
|
1022
|
+
|
|
1023
|
+
async def submit(self, job: dict) -> str:
|
|
1024
|
+
self.jobs[job["id"]] = job
|
|
1025
|
+
await self.queue.put(job["id"])
|
|
1026
|
+
self._persist()
|
|
1027
|
+
return job["id"]
|
|
1028
|
+
|
|
1029
|
+
async def cancel_all(self, reason: str = "已手动停止") -> dict:
|
|
1030
|
+
'''停止当前生图任务:清空排队、中断在途请求、阻止换号重试。
|
|
1031
|
+
|
|
1032
|
+
已出图和已写入历史的任务不受影响;被停止的任务不写历史、不落盘,
|
|
1033
|
+
重启后也不会被 _restore 捞回。
|
|
1034
|
+
'''
|
|
1035
|
+
stopped: list[str] = []
|
|
1036
|
+
dropped = 0
|
|
1037
|
+
while True:
|
|
1038
|
+
try:
|
|
1039
|
+
jid = self.queue.get_nowait()
|
|
1040
|
+
except asyncio.QueueEmpty:
|
|
1041
|
+
break
|
|
1042
|
+
job = self.jobs.get(jid)
|
|
1043
|
+
if job and job.get("status") in ("queued", "running"):
|
|
1044
|
+
job.update({"status": "cancelled", "error": reason,
|
|
1045
|
+
"will_retry": 0, "finished": int(time.time())})
|
|
1046
|
+
stopped.append(jid)
|
|
1047
|
+
dropped += 1
|
|
1048
|
+
self.queue.task_done()
|
|
1049
|
+
# 先改状态再 cancel:_run_job 的 finally 才不会写历史或安排重试
|
|
1050
|
+
running = []
|
|
1051
|
+
for jid, task in list(self._job_tasks.items()):
|
|
1052
|
+
job = self.jobs.get(jid)
|
|
1053
|
+
if job:
|
|
1054
|
+
job.update({"status": "cancelled", "error": reason, "will_retry": 0})
|
|
1055
|
+
running.append(jid)
|
|
1056
|
+
stopped.append(jid)
|
|
1057
|
+
if not task.done():
|
|
1058
|
+
task.cancel()
|
|
1059
|
+
# 换号重试等待中的任务没有执行体,也要一并停掉
|
|
1060
|
+
for jid, job in self.jobs.items():
|
|
1061
|
+
if job.get("status") in ("queued", "running") and jid not in stopped:
|
|
1062
|
+
job.update({"status": "cancelled", "error": reason,
|
|
1063
|
+
"will_retry": 0, "finished": int(time.time())})
|
|
1064
|
+
stopped.append(jid)
|
|
1065
|
+
self.running.clear()
|
|
1066
|
+
self._job_tasks.clear()
|
|
1067
|
+
self._persist()
|
|
1068
|
+
log(f"手动停止生图:中断执行中 {len(running)} 个 / 丢弃排队 {dropped} 个")
|
|
1069
|
+
return {"ok": True, "stopped": len(stopped), "running": len(running),
|
|
1070
|
+
"queued": dropped,
|
|
1071
|
+
"message": f"已停止 {len(stopped)} 个任务(执行中 {len(running)}、排队 {dropped})"}
|
|
1072
|
+
|
|
1073
|
+
def _finish(self, job: dict) -> None:
|
|
1074
|
+
job.setdefault("finished", int(time.time()))
|
|
1075
|
+
if self.on_finish:
|
|
1076
|
+
try:
|
|
1077
|
+
self.on_finish(job)
|
|
1078
|
+
except Exception as e:
|
|
1079
|
+
log(f"history record failed: {mask(e)[:120]}")
|
|
1080
|
+
|
|
1081
|
+
# ---------- workers ----------
|
|
1082
|
+
async def _worker(self, wid: int) -> None:
|
|
1083
|
+
while True:
|
|
1084
|
+
job_id = await self.queue.get()
|
|
1085
|
+
try:
|
|
1086
|
+
job = self.jobs.get(job_id)
|
|
1087
|
+
if not job or job.get("status") == "cancelled":
|
|
1088
|
+
continue
|
|
1089
|
+
acc = await self._claim()
|
|
1090
|
+
task = asyncio.create_task(self._run_job(job, acc, wid))
|
|
1091
|
+
self._job_tasks[job_id] = task
|
|
1092
|
+
try:
|
|
1093
|
+
await task
|
|
1094
|
+
except asyncio.CancelledError:
|
|
1095
|
+
# 单个任务被手动停止:worker 继续取下一条,不能让整个 worker 死掉
|
|
1096
|
+
log(f"job {job_id}: 已手动停止")
|
|
1097
|
+
finally:
|
|
1098
|
+
self._job_tasks.pop(job_id, None)
|
|
1099
|
+
except asyncio.CancelledError:
|
|
1100
|
+
raise
|
|
1101
|
+
except Exception as e: # worker 绝不能死
|
|
1102
|
+
log(f"worker {wid}: unexpected {mask(e)[:160]}")
|
|
1103
|
+
finally:
|
|
1104
|
+
self.queue.task_done()
|
|
1105
|
+
|
|
1106
|
+
async def _claim(self, avoid_env=None) -> dict:
|
|
1107
|
+
"""号池自己选号与轮转,这里只占一个本地槽位做状态展示。"""
|
|
1108
|
+
async with self._lock:
|
|
1109
|
+
acc = self.endpoint
|
|
1110
|
+
acc["busy_since"] = time.time()
|
|
1111
|
+
return acc
|
|
1112
|
+
|
|
1113
|
+
async def _run_job(self, job: dict, acc: dict, wid: int) -> None:
|
|
1114
|
+
job_id = job["id"]
|
|
1115
|
+
self.running[job_id] = {"account": acc["masked"], "env_id": acc["env_id"],
|
|
1116
|
+
"started": int(time.time())}
|
|
1117
|
+
job.update({"status": "running", "account": acc["masked"], "env_id": acc["env_id"],
|
|
1118
|
+
"last_env": acc["env_id"], "started": int(time.time()), "worker": wid})
|
|
1119
|
+
log(f"job {job_id}: start mode={job['mode']} worker={wid} "
|
|
1120
|
+
f"prompt_chars={len(job.get('prompt') or '')}")
|
|
1121
|
+
t_call = time.time()
|
|
1122
|
+
try:
|
|
1123
|
+
try:
|
|
1124
|
+
result = await self._execute(acc, job)
|
|
1125
|
+
if job.get("status") == "cancelled":
|
|
1126
|
+
return
|
|
1127
|
+
job["result"] = result
|
|
1128
|
+
job["status"] = "done"
|
|
1129
|
+
acc["uses"] += 1
|
|
1130
|
+
acc["fails"] = 0
|
|
1131
|
+
acc["last_ok"] = time.time()
|
|
1132
|
+
acc["error"] = ""
|
|
1133
|
+
# 成功也回查一次账号:用户能看见「这次是谁出的图」,
|
|
1134
|
+
# 重试时也能直观看出确实换了号
|
|
1135
|
+
email = await self._attribute_call(job, t_call)
|
|
1136
|
+
if email:
|
|
1137
|
+
job["account_email"] = email
|
|
1138
|
+
job["account"] = mask_email(email)
|
|
1139
|
+
job["result"]["account"] = mask_email(email)
|
|
1140
|
+
job["result"]["account_email"] = email
|
|
1141
|
+
log(f"job {job_id}: done ({(job.get('result') or {}).get('bytes') or 'text'})"
|
|
1142
|
+
+ (f" by {mask_email(email)}" if email else "")
|
|
1143
|
+
+ (f" after {job['auto_retry']} retry" if job.get("auto_retry") else ""))
|
|
1144
|
+
except Exception as e:
|
|
1145
|
+
if job.get("status") == "cancelled":
|
|
1146
|
+
return
|
|
1147
|
+
err = mask(f"{type(e).__name__}: {e}")[:300]
|
|
1148
|
+
acc["fails"] += 1
|
|
1149
|
+
acc["last_error_at"] = time.time()
|
|
1150
|
+
acc["error"] = err[:160]
|
|
1151
|
+
# 先查清楚是哪个号失败的,再排队重试(重新入队=号池换下一个号)
|
|
1152
|
+
email = await self._attribute_call(job, t_call)
|
|
1153
|
+
attempt = {
|
|
1154
|
+
"n": len(job.get("attempts") or []) + 1,
|
|
1155
|
+
"account": mask_email(email) if email else "未知账号",
|
|
1156
|
+
"account_email": email,
|
|
1157
|
+
"error": err[:200],
|
|
1158
|
+
"at": int(time.time()),
|
|
1159
|
+
}
|
|
1160
|
+
job.setdefault("attempts", []).append(attempt)
|
|
1161
|
+
if email:
|
|
1162
|
+
attempt["count_today"] = record_account_fail(email, err)
|
|
1163
|
+
log(f"job {job_id}: attempt #{attempt['n']} failed on {attempt['account']}: "
|
|
1164
|
+
f"{err[:120]}")
|
|
1165
|
+
self._schedule_retry(job, err)
|
|
1166
|
+
finally:
|
|
1167
|
+
job["finished"] = int(time.time())
|
|
1168
|
+
self.running.pop(job_id, None)
|
|
1169
|
+
acc["last_used"] = time.time()
|
|
1170
|
+
acc["busy_since"] = 0.0
|
|
1171
|
+
if job["status"] in ("done", "error"):
|
|
1172
|
+
self._finish(job)
|
|
1173
|
+
self._persist()
|
|
1174
|
+
|
|
1175
|
+
def _schedule_retry(self, job: dict, err: str) -> None:
|
|
1176
|
+
if job.get("status") == "cancelled":
|
|
1177
|
+
return
|
|
1178
|
+
n = int(job.get("auto_retry") or 0)
|
|
1179
|
+
if n >= RETRY_MAX:
|
|
1180
|
+
job["status"] = "error"
|
|
1181
|
+
job["will_retry"] = 0
|
|
1182
|
+
# 失败原因里带上「换过哪几个号」,用户一眼能看出不是同一个号反复撞
|
|
1183
|
+
names = list(dict.fromkeys(
|
|
1184
|
+
a.get("account") for a in (job.get("attempts") or []) if a.get("account")))
|
|
1185
|
+
job["error"] = (f"{err}(已换 {len(names)} 个号重试 {n} 次仍未成功:"
|
|
1186
|
+
f"{'、'.join(names)})") if names else err
|
|
1187
|
+
log(f"job {job['id']}: FAILED for good after {n} retries "
|
|
1188
|
+
f"on {len(names)} accounts: {err}")
|
|
1189
|
+
return
|
|
1190
|
+
delay = RETRY_DELAYS[min(n, len(RETRY_DELAYS) - 1)]
|
|
1191
|
+
job["auto_retry"] = n + 1
|
|
1192
|
+
job["will_retry"] = 1
|
|
1193
|
+
job["status"] = "queued"
|
|
1194
|
+
job["error"] = err
|
|
1195
|
+
job["retry_at"] = int(time.time() + delay)
|
|
1196
|
+
log(f"job {job['id']}: {err} -> retry #{n + 1} in {delay}s")
|
|
1197
|
+
self._persist()
|
|
1198
|
+
asyncio.create_task(self._requeue_later(job["id"], delay))
|
|
1199
|
+
|
|
1200
|
+
async def _requeue_later(self, job_id: str, delay: float) -> None:
|
|
1201
|
+
try:
|
|
1202
|
+
await asyncio.sleep(delay)
|
|
1203
|
+
job = self.jobs.get(job_id)
|
|
1204
|
+
if not job or job.get("status") != "queued":
|
|
1205
|
+
return
|
|
1206
|
+
self.queue.put_nowait(job_id)
|
|
1207
|
+
except Exception as e:
|
|
1208
|
+
log(f"requeue {job_id}: {mask(e)[:120]}")
|
|
1209
|
+
|
|
1210
|
+
# ---------- 一次任务:号池 API ----------
|
|
1211
|
+
async def _post_edits(self, c, p, auth, payload, ref_bytes):
|
|
1212
|
+
"""参考图:先按配置方式试,auto 时先 json(号池)失败再退标准 multipart。"""
|
|
1213
|
+
if p["edits_style"] == "json":
|
|
1214
|
+
styles = ["json"]
|
|
1215
|
+
elif p["edits_style"] == "multipart":
|
|
1216
|
+
styles = ["multipart"]
|
|
1217
|
+
else:
|
|
1218
|
+
styles = ["json", "multipart"]
|
|
1219
|
+
last = None
|
|
1220
|
+
for st in styles:
|
|
1221
|
+
try:
|
|
1222
|
+
if st == "json":
|
|
1223
|
+
body = dict(payload)
|
|
1224
|
+
body.pop("response_format", None)
|
|
1225
|
+
body["images"] = ["data:image/png;base64,"
|
|
1226
|
+
+ base64.b64encode(ref_bytes).decode("ascii")]
|
|
1227
|
+
r = await c.post(api_url(p, "images/edits"), json=body, headers=auth)
|
|
1228
|
+
else:
|
|
1229
|
+
data = {"model": payload["model"], "prompt": payload["prompt"], "n": "1"}
|
|
1230
|
+
if payload.get("size"):
|
|
1231
|
+
data["size"] = payload["size"]
|
|
1232
|
+
r = await c.post(api_url(p, "images/edits"), data=data,
|
|
1233
|
+
files={"image": ("ref.png", ref_bytes, "image/png")},
|
|
1234
|
+
headers={"Authorization": auth["Authorization"]})
|
|
1235
|
+
last = r
|
|
1236
|
+
if r.status_code < 400:
|
|
1237
|
+
return r, "api:edits"
|
|
1238
|
+
except Exception:
|
|
1239
|
+
continue
|
|
1240
|
+
return last, "api:edits"
|
|
1241
|
+
|
|
1242
|
+
def _dl_headers(self, url, p):
|
|
1243
|
+
"""下载图片:仅当图片与接口同源时才带密钥,避免把 key 送给外部 CDN。"""
|
|
1244
|
+
if not url.lower().startswith("http"):
|
|
1245
|
+
return {}
|
|
1246
|
+
try:
|
|
1247
|
+
same = urlparse(url).netloc == urlparse(p["base_url"]).netloc
|
|
1248
|
+
except Exception:
|
|
1249
|
+
same = False
|
|
1250
|
+
return {"Authorization": "Bearer " + p["api_key"]} if same else {}
|
|
1251
|
+
|
|
1252
|
+
async def _execute(self, acc: dict, job: dict) -> dict:
|
|
1253
|
+
p = provider_cfg()
|
|
1254
|
+
key = p["api_key"]
|
|
1255
|
+
if not key:
|
|
1256
|
+
raise RuntimeError("未配置接口 API Key,请打开「接口设置」填写 Base URL 与密钥")
|
|
1257
|
+
headers = {"Authorization": "Bearer " + key,
|
|
1258
|
+
"Content-Type": "application/json"}
|
|
1259
|
+
timeout = IMAGE_TIMEOUT if job["mode"] == "image" else TEXT_TIMEOUT
|
|
1260
|
+
async with httpx.AsyncClient(trust_env=False, timeout=timeout) as c:
|
|
1261
|
+
if job["mode"] == "image":
|
|
1262
|
+
payload = {"model": p["model_image"], "prompt": job["prompt"],
|
|
1263
|
+
"n": 1, "quality": "auto"}
|
|
1264
|
+
# 返回格式:显式配置优先;auto 时号池默认 b64,第三方交给服务端默认
|
|
1265
|
+
if p["image_format"] in ("b64_json", "url"):
|
|
1266
|
+
payload["response_format"] = p["image_format"]
|
|
1267
|
+
elif p["is_pool"]:
|
|
1268
|
+
payload["response_format"] = "b64_json"
|
|
1269
|
+
sz = size_for_ratio(job.get("ratio"))
|
|
1270
|
+
if sz:
|
|
1271
|
+
payload["size"] = sz
|
|
1272
|
+
ref = job.get("ref_path") or ""
|
|
1273
|
+
ref_bytes = Path(ref).read_bytes() if (ref and Path(ref).is_file()) else b""
|
|
1274
|
+
r, via = None, "api:generations"
|
|
1275
|
+
if ref_bytes and p["supports_edits"] and p["edits_style"] != "off":
|
|
1276
|
+
r, via = await self._post_edits(c, p, headers, payload, ref_bytes)
|
|
1277
|
+
if r is None or r.status_code >= 400:
|
|
1278
|
+
# 参考图走 edits 失败:不静默降级,明确告诉用户怎么改
|
|
1279
|
+
if r is not None and r.status_code in (400, 404, 405, 415, 422, 501):
|
|
1280
|
+
raise RuntimeError(
|
|
1281
|
+
"当前接口不支持参考图(/v1/images/edits 不可用)。"
|
|
1282
|
+
"请到「接口设置」关闭「支持参考图」,或换成支持 edits 的接口后重试。")
|
|
1283
|
+
raise_for_response(r)
|
|
1284
|
+
else:
|
|
1285
|
+
r = await c.post(api_url(p, "images/generations"), json=payload, headers=headers)
|
|
1286
|
+
via = "api:generations"
|
|
1287
|
+
if r.status_code != 200:
|
|
1288
|
+
raise_for_response(r)
|
|
1289
|
+
items = (_json_of(r).get("data") or [])
|
|
1290
|
+
if not items:
|
|
1291
|
+
raise RuntimeError("接口返回成功但没有图片数据(确认该地址是 OpenAI 兼容的 images 接口)")
|
|
1292
|
+
item = items[0] or {}
|
|
1293
|
+
raw = b""
|
|
1294
|
+
if item.get("b64_json"):
|
|
1295
|
+
raw = base64.b64decode(item["b64_json"])
|
|
1296
|
+
elif item.get("url"):
|
|
1297
|
+
u = str(item["url"])
|
|
1298
|
+
if u.startswith("data:"):
|
|
1299
|
+
raw = base64.b64decode(u.split(",", 1)[1])
|
|
1300
|
+
else:
|
|
1301
|
+
rr = await c.get(u, headers=self._dl_headers(u, p))
|
|
1302
|
+
if rr.status_code != 200:
|
|
1303
|
+
raise RuntimeError(f"下载图片失败 HTTP {rr.status_code}")
|
|
1304
|
+
raw = rr.content
|
|
1305
|
+
if not raw:
|
|
1306
|
+
raise RuntimeError("图片数据为空")
|
|
1307
|
+
# 存到用户配置的保存目录(默认 images/),目录不可用时 out_dir 会退回默认
|
|
1308
|
+
save_to = out_dir()
|
|
1309
|
+
save_to.mkdir(parents=True, exist_ok=True)
|
|
1310
|
+
final = save_to / f"{job['id']}.png"
|
|
1311
|
+
with Image.open(io.BytesIO(raw)) as im:
|
|
1312
|
+
w, h = im.size
|
|
1313
|
+
im.convert("RGB").save(final, "PNG")
|
|
1314
|
+
return {"image": final.name, "bytes": final.stat().st_size,
|
|
1315
|
+
"width": w, "height": h, "via": via,
|
|
1316
|
+
"saved_to": str(save_to)}
|
|
1317
|
+
|
|
1318
|
+
payload = {
|
|
1319
|
+
"model": p["model_text"],
|
|
1320
|
+
"messages": [{"role": "user", "content": job["prompt"]}],
|
|
1321
|
+
"stream": False,
|
|
1322
|
+
# 唯一 user 强制穿透号池 60 秒聊天缓存,保证每次真的去取号轮转
|
|
1323
|
+
"user": f"wb-{job['id']}-{int(time.time())}",
|
|
1324
|
+
}
|
|
1325
|
+
r = await c.post(api_url(p, "chat/completions"), json=payload, headers=headers)
|
|
1326
|
+
if r.status_code != 200:
|
|
1327
|
+
raise_for_response(r)
|
|
1328
|
+
data = _json_of(r)
|
|
1329
|
+
try:
|
|
1330
|
+
text = str(data["choices"][0]["message"]["content"] or "").strip()
|
|
1331
|
+
except Exception:
|
|
1332
|
+
text = str(data.get("text") or "").strip()
|
|
1333
|
+
if not text:
|
|
1334
|
+
raise RuntimeError("文案返回为空")
|
|
1335
|
+
return {"text": text}
|