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
|
@@ -0,0 +1,337 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import hashlib
|
|
4
|
+
import json
|
|
5
|
+
import itertools
|
|
6
|
+
import time
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
from typing import Any
|
|
11
|
+
from uuid import uuid4
|
|
12
|
+
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
from fastapi.concurrency import run_in_threadpool
|
|
15
|
+
from fastapi.responses import JSONResponse, StreamingResponse
|
|
16
|
+
|
|
17
|
+
from services.config import DATA_DIR
|
|
18
|
+
from services.protocol.error_response import anthropic_error_response, openai_error_response
|
|
19
|
+
from utils.helper import anthropic_sse_stream, sse_json_stream
|
|
20
|
+
|
|
21
|
+
LOG_TYPE_CALL = "call"
|
|
22
|
+
LOG_TYPE_ACCOUNT = "account"
|
|
23
|
+
INTERNAL_RESPONSE_KEYS = {"_account_email", "_conversation_id"}
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class LogService:
|
|
27
|
+
def __init__(self, path: Path):
|
|
28
|
+
self.path = path
|
|
29
|
+
self.path.parent.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
@staticmethod
|
|
32
|
+
def _legacy_id(raw_line: str, line_number: int) -> str:
|
|
33
|
+
payload = f"{line_number}:{raw_line}".encode("utf-8", errors="ignore")
|
|
34
|
+
return hashlib.sha1(payload).hexdigest()[:24]
|
|
35
|
+
|
|
36
|
+
def _parse_line(self, raw_line: str, line_number: int) -> dict[str, Any] | None:
|
|
37
|
+
try:
|
|
38
|
+
item = json.loads(raw_line)
|
|
39
|
+
except Exception:
|
|
40
|
+
return None
|
|
41
|
+
if not isinstance(item, dict):
|
|
42
|
+
return None
|
|
43
|
+
parsed = dict(item)
|
|
44
|
+
parsed["id"] = str(parsed.get("id") or self._legacy_id(raw_line, line_number))
|
|
45
|
+
return parsed
|
|
46
|
+
|
|
47
|
+
@staticmethod
|
|
48
|
+
def _serialize_item(item: dict[str, Any]) -> str:
|
|
49
|
+
return json.dumps(item, ensure_ascii=False, separators=(",", ":"))
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _matches_filters(item: dict[str, Any], *, type: str = "", start_date: str = "", end_date: str = "") -> bool:
|
|
53
|
+
t = str(item.get("time") or "")
|
|
54
|
+
day = t[:10]
|
|
55
|
+
if type and item.get("type") != type:
|
|
56
|
+
return False
|
|
57
|
+
if start_date and day < start_date:
|
|
58
|
+
return False
|
|
59
|
+
if end_date and day > end_date:
|
|
60
|
+
return False
|
|
61
|
+
return True
|
|
62
|
+
|
|
63
|
+
def add(self, type: str, summary: str = "", detail: dict[str, Any] | None = None, **data: Any) -> None:
|
|
64
|
+
item = {
|
|
65
|
+
"id": uuid4().hex,
|
|
66
|
+
"time": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
67
|
+
"type": type,
|
|
68
|
+
"summary": summary,
|
|
69
|
+
"detail": detail or data,
|
|
70
|
+
}
|
|
71
|
+
with self.path.open("a", encoding="utf-8") as file:
|
|
72
|
+
file.write(self._serialize_item(item) + "\n")
|
|
73
|
+
|
|
74
|
+
def list(self, type: str = "", start_date: str = "", end_date: str = "", limit: int = 200) -> list[dict[str, Any]]:
|
|
75
|
+
if not self.path.exists():
|
|
76
|
+
return []
|
|
77
|
+
items: list[dict[str, Any]] = []
|
|
78
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
79
|
+
for line_number in range(len(lines) - 1, -1, -1):
|
|
80
|
+
item = self._parse_line(lines[line_number], line_number)
|
|
81
|
+
if item is None:
|
|
82
|
+
continue
|
|
83
|
+
if not self._matches_filters(item, type=type, start_date=start_date, end_date=end_date):
|
|
84
|
+
continue
|
|
85
|
+
items.append(item)
|
|
86
|
+
if len(items) >= limit:
|
|
87
|
+
break
|
|
88
|
+
return items
|
|
89
|
+
|
|
90
|
+
def delete(self, ids: list[str]) -> dict[str, int]:
|
|
91
|
+
target_ids = {str(item or "").strip() for item in ids if str(item or "").strip()}
|
|
92
|
+
if not self.path.exists() or not target_ids:
|
|
93
|
+
return {"removed": 0}
|
|
94
|
+
lines = self.path.read_text(encoding="utf-8").splitlines()
|
|
95
|
+
kept_lines: list[str] = []
|
|
96
|
+
removed = 0
|
|
97
|
+
for line_number, raw_line in enumerate(lines):
|
|
98
|
+
item = self._parse_line(raw_line, line_number)
|
|
99
|
+
if item is None:
|
|
100
|
+
kept_lines.append(raw_line)
|
|
101
|
+
continue
|
|
102
|
+
if str(item.get("id") or "") in target_ids:
|
|
103
|
+
removed += 1
|
|
104
|
+
continue
|
|
105
|
+
kept_lines.append(self._serialize_item(item))
|
|
106
|
+
content = "\n".join(kept_lines)
|
|
107
|
+
if content:
|
|
108
|
+
content += "\n"
|
|
109
|
+
self.path.write_text(content, encoding="utf-8")
|
|
110
|
+
return {"removed": removed}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
log_service = LogService(DATA_DIR / "logs.jsonl")
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def _collect_urls(value: object) -> list[str]:
|
|
117
|
+
urls: list[str] = []
|
|
118
|
+
if isinstance(value, dict):
|
|
119
|
+
for key, item in value.items():
|
|
120
|
+
if key == "url" and isinstance(item, str):
|
|
121
|
+
urls.append(item)
|
|
122
|
+
elif key == "urls" and isinstance(item, list):
|
|
123
|
+
urls.extend(str(url) for url in item if isinstance(url, str))
|
|
124
|
+
else:
|
|
125
|
+
urls.extend(_collect_urls(item))
|
|
126
|
+
elif isinstance(value, list):
|
|
127
|
+
for item in value:
|
|
128
|
+
urls.extend(_collect_urls(item))
|
|
129
|
+
return urls
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _collect_account_emails(value: object) -> list[str]:
|
|
133
|
+
emails: list[str] = []
|
|
134
|
+
if isinstance(value, dict):
|
|
135
|
+
for key, item in value.items():
|
|
136
|
+
if key in {"_account_email", "account_email"} and isinstance(item, str) and item.strip():
|
|
137
|
+
emails.append(item.strip())
|
|
138
|
+
else:
|
|
139
|
+
emails.extend(_collect_account_emails(item))
|
|
140
|
+
elif isinstance(value, list):
|
|
141
|
+
for item in value:
|
|
142
|
+
emails.extend(_collect_account_emails(item))
|
|
143
|
+
return emails
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _collect_conversation_ids(value: object) -> list[str]:
|
|
147
|
+
ids: list[str] = []
|
|
148
|
+
if isinstance(value, dict):
|
|
149
|
+
for key, item in value.items():
|
|
150
|
+
if key == "_conversation_id" and isinstance(item, str) and item.strip():
|
|
151
|
+
ids.append(item.strip())
|
|
152
|
+
else:
|
|
153
|
+
ids.extend(_collect_conversation_ids(item))
|
|
154
|
+
elif isinstance(value, list):
|
|
155
|
+
for item in value:
|
|
156
|
+
ids.extend(_collect_conversation_ids(item))
|
|
157
|
+
return ids
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def _strip_internal_response_fields(value: object) -> object:
|
|
161
|
+
if isinstance(value, dict):
|
|
162
|
+
return {
|
|
163
|
+
key: _strip_internal_response_fields(item)
|
|
164
|
+
for key, item in value.items()
|
|
165
|
+
if key not in INTERNAL_RESPONSE_KEYS
|
|
166
|
+
}
|
|
167
|
+
if isinstance(value, list):
|
|
168
|
+
return [_strip_internal_response_fields(item) for item in value]
|
|
169
|
+
return value
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _request_excerpt(text: object, limit: int = 1000) -> str:
|
|
173
|
+
value = str(text or "").strip()
|
|
174
|
+
if not value:
|
|
175
|
+
return ""
|
|
176
|
+
normalized = " ".join(value.split())
|
|
177
|
+
if len(normalized) <= limit:
|
|
178
|
+
return normalized
|
|
179
|
+
return normalized[: limit - 1].rstrip() + "…"
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _image_error_response(exc: Exception) -> JSONResponse:
|
|
183
|
+
from services.protocol.conversation import public_image_error_message
|
|
184
|
+
|
|
185
|
+
message = public_image_error_message(str(exc))
|
|
186
|
+
if "no available image quota" in message.lower():
|
|
187
|
+
return openai_error_response(
|
|
188
|
+
{
|
|
189
|
+
"error": {
|
|
190
|
+
"message": "no available image quota",
|
|
191
|
+
"type": "insufficient_quota",
|
|
192
|
+
"param": None,
|
|
193
|
+
"code": "insufficient_quota",
|
|
194
|
+
}
|
|
195
|
+
},
|
|
196
|
+
429,
|
|
197
|
+
)
|
|
198
|
+
if hasattr(exc, "to_openai_error") and hasattr(exc, "status_code"):
|
|
199
|
+
return JSONResponse(status_code=int(exc.status_code), content=exc.to_openai_error())
|
|
200
|
+
return openai_error_response(message, 502)
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _protocol_error_response(exc: Exception, status_code: int, sse: str) -> JSONResponse:
|
|
204
|
+
message = str(exc)
|
|
205
|
+
if sse == "anthropic":
|
|
206
|
+
return anthropic_error_response(message, status_code)
|
|
207
|
+
return openai_error_response(message, status_code)
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _next_item(items):
|
|
211
|
+
try:
|
|
212
|
+
return True, next(items)
|
|
213
|
+
except StopIteration:
|
|
214
|
+
return False, None
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
@dataclass
|
|
218
|
+
class LoggedCall:
|
|
219
|
+
identity: dict[str, object]
|
|
220
|
+
endpoint: str
|
|
221
|
+
model: str
|
|
222
|
+
summary: str
|
|
223
|
+
started: float = field(default_factory=time.time)
|
|
224
|
+
request_text: str = ""
|
|
225
|
+
request_shape: dict[str, int] | None = None
|
|
226
|
+
|
|
227
|
+
async def run(self, handler, *args, sse: str = "openai"):
|
|
228
|
+
from services.protocol.conversation import ImageGenerationError
|
|
229
|
+
|
|
230
|
+
try:
|
|
231
|
+
result = await run_in_threadpool(handler, *args)
|
|
232
|
+
except ImageGenerationError as exc:
|
|
233
|
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
|
|
234
|
+
conversation_id=getattr(exc, "conversation_id", ""))
|
|
235
|
+
return _image_error_response(exc)
|
|
236
|
+
except HTTPException as exc:
|
|
237
|
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
|
238
|
+
raise
|
|
239
|
+
except Exception as exc:
|
|
240
|
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
|
241
|
+
if self.endpoint.startswith("/v1/images"):
|
|
242
|
+
return _image_error_response(exc)
|
|
243
|
+
return _protocol_error_response(exc, 502, sse)
|
|
244
|
+
|
|
245
|
+
if isinstance(result, dict):
|
|
246
|
+
self.log("调用完成", result)
|
|
247
|
+
response = dict(result)
|
|
248
|
+
response.pop("_account_email", None)
|
|
249
|
+
return response
|
|
250
|
+
|
|
251
|
+
sender = anthropic_sse_stream if sse == "anthropic" else sse_json_stream
|
|
252
|
+
try:
|
|
253
|
+
has_first, first = await run_in_threadpool(_next_item, result)
|
|
254
|
+
except ImageGenerationError as exc:
|
|
255
|
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""),
|
|
256
|
+
conversation_id=getattr(exc, "conversation_id", ""))
|
|
257
|
+
return _image_error_response(exc)
|
|
258
|
+
except HTTPException as exc:
|
|
259
|
+
self.log("调用失败", status="failed", error=str(exc.detail))
|
|
260
|
+
raise
|
|
261
|
+
except Exception as exc:
|
|
262
|
+
self.log("调用失败", status="failed", error=str(exc), account_email=getattr(exc, "account_email", ""))
|
|
263
|
+
if self.endpoint.startswith("/v1/images"):
|
|
264
|
+
return _image_error_response(exc)
|
|
265
|
+
return _protocol_error_response(exc, 502, sse)
|
|
266
|
+
if not has_first:
|
|
267
|
+
self.log("流式调用结束")
|
|
268
|
+
return StreamingResponse(sender(()), media_type="text/event-stream")
|
|
269
|
+
return StreamingResponse(sender(self.stream(itertools.chain([first], result))), media_type="text/event-stream")
|
|
270
|
+
|
|
271
|
+
def stream(self, items):
|
|
272
|
+
urls: list[str] = []
|
|
273
|
+
account_emails: list[str] = []
|
|
274
|
+
conversation_ids: list[str] = []
|
|
275
|
+
failed = False
|
|
276
|
+
try:
|
|
277
|
+
for item in items:
|
|
278
|
+
urls.extend(_collect_urls(item))
|
|
279
|
+
account_emails.extend(_collect_account_emails(item))
|
|
280
|
+
conversation_ids.extend(_collect_conversation_ids(item))
|
|
281
|
+
yield _strip_internal_response_fields(item)
|
|
282
|
+
except Exception as exc:
|
|
283
|
+
failed = True
|
|
284
|
+
self.log(
|
|
285
|
+
"流式调用失败",
|
|
286
|
+
status="failed",
|
|
287
|
+
error=str(exc),
|
|
288
|
+
urls=urls,
|
|
289
|
+
account_email=(account_emails[0] if account_emails else getattr(exc, "account_email", "")),
|
|
290
|
+
conversation_id=(conversation_ids[0] if conversation_ids else getattr(exc, "conversation_id", "")),
|
|
291
|
+
)
|
|
292
|
+
if self.endpoint.startswith("/v1/images") and not hasattr(exc, "to_openai_error"):
|
|
293
|
+
from services.protocol.conversation import ImageGenerationError, public_image_error_message
|
|
294
|
+
|
|
295
|
+
raise ImageGenerationError(public_image_error_message(str(exc))) from exc
|
|
296
|
+
raise
|
|
297
|
+
finally:
|
|
298
|
+
if not failed:
|
|
299
|
+
self.log("流式调用结束", urls=urls, account_email=account_emails[0] if account_emails else "",
|
|
300
|
+
conversation_id=conversation_ids[0] if conversation_ids else "")
|
|
301
|
+
|
|
302
|
+
def log(self, suffix: str, result: object = None, status: str = "success", error: str = "",
|
|
303
|
+
urls: list[str] | None = None, account_email: str = "", conversation_id: str = "") -> None:
|
|
304
|
+
detail = {
|
|
305
|
+
"key_id": self.identity.get("id"),
|
|
306
|
+
"key_name": self.identity.get("name"),
|
|
307
|
+
"role": self.identity.get("role"),
|
|
308
|
+
"endpoint": self.endpoint,
|
|
309
|
+
"model": self.model,
|
|
310
|
+
"started_at": datetime.fromtimestamp(self.started).strftime("%Y-%m-%d %H:%M:%S"),
|
|
311
|
+
"ended_at": datetime.now().strftime("%Y-%m-%d %H:%M:%S"),
|
|
312
|
+
"duration_ms": int((time.time() - self.started) * 1000),
|
|
313
|
+
"status": status,
|
|
314
|
+
}
|
|
315
|
+
request_excerpt = _request_excerpt(self.request_text)
|
|
316
|
+
if request_excerpt:
|
|
317
|
+
detail["request_text"] = request_excerpt
|
|
318
|
+
if self.request_shape:
|
|
319
|
+
detail["request_shape"] = self.request_shape
|
|
320
|
+
if error:
|
|
321
|
+
detail["error"] = error
|
|
322
|
+
email = str(account_email or "").strip()
|
|
323
|
+
if not email:
|
|
324
|
+
emails = _collect_account_emails(result)
|
|
325
|
+
email = emails[0] if emails else ""
|
|
326
|
+
if email:
|
|
327
|
+
detail["account_email"] = email
|
|
328
|
+
conv_id = str(conversation_id or "").strip()
|
|
329
|
+
if not conv_id:
|
|
330
|
+
conv_ids = _collect_conversation_ids(result)
|
|
331
|
+
conv_id = conv_ids[0] if conv_ids else ""
|
|
332
|
+
if conv_id:
|
|
333
|
+
detail["conversation_id"] = conv_id
|
|
334
|
+
collected_urls = [*(urls or []), *_collect_urls(result)]
|
|
335
|
+
if collected_urls and not self.endpoint.startswith("/v1/search"):
|
|
336
|
+
detail["urls"] = list(dict.fromkeys(collected_urls))
|
|
337
|
+
log_service.add(LOG_TYPE_CALL, f"{self.summary}{suffix}", detail)
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import time
|
|
4
|
+
from collections.abc import Callable
|
|
5
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from threading import RLock
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from services.account_service import AccountService, account_service
|
|
11
|
+
from services.openai_backend_api import OpenAIBackendAPI
|
|
12
|
+
from utils.log import logger
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class ModelRoute:
|
|
17
|
+
account_types: frozenset[str]
|
|
18
|
+
allow_anonymous: bool = False
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class ModelUnavailableError(RuntimeError):
|
|
22
|
+
pass
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
class ModelCatalogService:
|
|
26
|
+
"""Caches the model catalogs advertised to each active account type."""
|
|
27
|
+
|
|
28
|
+
def __init__(
|
|
29
|
+
self,
|
|
30
|
+
accounts: AccountService,
|
|
31
|
+
*,
|
|
32
|
+
backend_factory: Callable[..., Any] = OpenAIBackendAPI,
|
|
33
|
+
cache_ttl_seconds: float = 300,
|
|
34
|
+
clock: Callable[[], float] = time.monotonic,
|
|
35
|
+
) -> None:
|
|
36
|
+
self._accounts = accounts
|
|
37
|
+
self._backend_factory = backend_factory
|
|
38
|
+
self._cache_ttl_seconds = max(1.0, float(cache_ttl_seconds))
|
|
39
|
+
self._clock = clock
|
|
40
|
+
self._lock = RLock()
|
|
41
|
+
self._expires_at = 0.0
|
|
42
|
+
self._account_signature: tuple[tuple[str, int], ...] = ()
|
|
43
|
+
self._anonymous_models: dict[str, dict[str, Any]] = {}
|
|
44
|
+
self._models_by_account_type: dict[str, dict[str, dict[str, Any]]] = {}
|
|
45
|
+
|
|
46
|
+
@staticmethod
|
|
47
|
+
def _model_map(result: object) -> dict[str, dict[str, Any]]:
|
|
48
|
+
if not isinstance(result, dict) or not isinstance(result.get("data"), list):
|
|
49
|
+
raise TypeError("upstream model response has no data list")
|
|
50
|
+
models: dict[str, dict[str, Any]] = {}
|
|
51
|
+
for item in result["data"]:
|
|
52
|
+
if not isinstance(item, dict):
|
|
53
|
+
continue
|
|
54
|
+
model_id = str(item.get("id") or "").strip()
|
|
55
|
+
if model_id and model_id not in models:
|
|
56
|
+
models[model_id] = dict(item)
|
|
57
|
+
return models
|
|
58
|
+
|
|
59
|
+
def _active_accounts_by_type(self) -> dict[str, list[str]]:
|
|
60
|
+
groups: dict[str, list[str]] = {}
|
|
61
|
+
for account in self._accounts.list_accounts():
|
|
62
|
+
if not isinstance(account, dict) or account.get("status") in {"禁用", "异常"}:
|
|
63
|
+
continue
|
|
64
|
+
access_token = str(account.get("access_token") or "").strip()
|
|
65
|
+
account_type = self._accounts._normalize_account_type(account.get("type"))
|
|
66
|
+
if access_token and account_type:
|
|
67
|
+
groups.setdefault(account_type, []).append(access_token)
|
|
68
|
+
return groups
|
|
69
|
+
|
|
70
|
+
@staticmethod
|
|
71
|
+
def _signature(groups: dict[str, list[str]]) -> tuple[tuple[str, int], ...]:
|
|
72
|
+
return tuple(
|
|
73
|
+
(account_type, len(tokens))
|
|
74
|
+
for account_type, tokens in sorted(groups.items())
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def _fetch_models(self, access_token: str = "") -> dict[str, dict[str, Any]]:
|
|
78
|
+
backend = self._backend_factory(access_token=access_token)
|
|
79
|
+
try:
|
|
80
|
+
return self._model_map(backend.list_models())
|
|
81
|
+
finally:
|
|
82
|
+
backend.close()
|
|
83
|
+
|
|
84
|
+
def _fetch_account_type_models(
|
|
85
|
+
self,
|
|
86
|
+
account_type: str,
|
|
87
|
+
access_tokens: list[str],
|
|
88
|
+
) -> dict[str, dict[str, Any]] | None:
|
|
89
|
+
attempted_tokens: set[str] = set()
|
|
90
|
+
last_error: Exception | None = None
|
|
91
|
+
for access_token in access_tokens:
|
|
92
|
+
try:
|
|
93
|
+
resolved_token = self._accounts.refresh_access_token(
|
|
94
|
+
access_token,
|
|
95
|
+
event="list_models",
|
|
96
|
+
) or access_token
|
|
97
|
+
if resolved_token in attempted_tokens:
|
|
98
|
+
continue
|
|
99
|
+
attempted_tokens.add(resolved_token)
|
|
100
|
+
return self._fetch_models(resolved_token)
|
|
101
|
+
except Exception as exc: # noqa: BLE001 - try the next account for any upstream failure
|
|
102
|
+
last_error = exc
|
|
103
|
+
if last_error is not None:
|
|
104
|
+
logger.warning({
|
|
105
|
+
"event": "model_catalog_account_type_failed",
|
|
106
|
+
"account_type": account_type,
|
|
107
|
+
"error_type": type(last_error).__name__,
|
|
108
|
+
})
|
|
109
|
+
return None
|
|
110
|
+
|
|
111
|
+
def _refresh(self, groups: dict[str, list[str]], signature: tuple[tuple[str, int], ...]) -> None:
|
|
112
|
+
models_by_account_type: dict[str, dict[str, dict[str, Any]]] = {}
|
|
113
|
+
with ThreadPoolExecutor(max_workers=min(4, len(groups) + 1)) as executor:
|
|
114
|
+
anonymous_future = executor.submit(self._fetch_models)
|
|
115
|
+
account_futures = {
|
|
116
|
+
account_type: executor.submit(
|
|
117
|
+
self._fetch_account_type_models,
|
|
118
|
+
account_type,
|
|
119
|
+
access_tokens,
|
|
120
|
+
)
|
|
121
|
+
for account_type, access_tokens in groups.items()
|
|
122
|
+
}
|
|
123
|
+
try:
|
|
124
|
+
anonymous_models = anonymous_future.result()
|
|
125
|
+
except Exception as exc: # noqa: BLE001 - retain cached models on upstream failure
|
|
126
|
+
logger.warning({
|
|
127
|
+
"event": "model_catalog_anonymous_failed",
|
|
128
|
+
"error_type": type(exc).__name__,
|
|
129
|
+
})
|
|
130
|
+
anonymous_models = self._anonymous_models
|
|
131
|
+
|
|
132
|
+
for account_type, future in account_futures.items():
|
|
133
|
+
models = future.result()
|
|
134
|
+
if models is not None:
|
|
135
|
+
models_by_account_type[account_type] = models
|
|
136
|
+
elif account_type in self._models_by_account_type:
|
|
137
|
+
models_by_account_type[account_type] = self._models_by_account_type[account_type]
|
|
138
|
+
|
|
139
|
+
self._anonymous_models = anonymous_models
|
|
140
|
+
self._models_by_account_type = models_by_account_type
|
|
141
|
+
self._account_signature = signature
|
|
142
|
+
self._expires_at = self._clock() + self._cache_ttl_seconds
|
|
143
|
+
|
|
144
|
+
def _ensure_catalog(self) -> None:
|
|
145
|
+
groups = self._active_accounts_by_type()
|
|
146
|
+
signature = self._signature(groups)
|
|
147
|
+
with self._lock:
|
|
148
|
+
if signature == self._account_signature and self._clock() < self._expires_at:
|
|
149
|
+
return
|
|
150
|
+
self._refresh(groups, signature)
|
|
151
|
+
|
|
152
|
+
def list_models(self) -> dict[str, Any]:
|
|
153
|
+
self._ensure_catalog()
|
|
154
|
+
with self._lock:
|
|
155
|
+
union: dict[str, dict[str, Any]] = {
|
|
156
|
+
model_id: dict(item)
|
|
157
|
+
for model_id, item in self._anonymous_models.items()
|
|
158
|
+
}
|
|
159
|
+
for account_type in sorted(self._models_by_account_type):
|
|
160
|
+
for model_id, item in self._models_by_account_type[account_type].items():
|
|
161
|
+
union.setdefault(model_id, dict(item))
|
|
162
|
+
return {
|
|
163
|
+
"object": "list",
|
|
164
|
+
"data": [union[model_id] for model_id in sorted(union)],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
def route_for_model(self, model: str) -> ModelRoute:
|
|
168
|
+
model = str(model or "").strip()
|
|
169
|
+
self._ensure_catalog()
|
|
170
|
+
with self._lock:
|
|
171
|
+
account_types = frozenset(
|
|
172
|
+
account_type
|
|
173
|
+
for account_type, models in self._models_by_account_type.items()
|
|
174
|
+
if model in models
|
|
175
|
+
)
|
|
176
|
+
return ModelRoute(
|
|
177
|
+
account_types=account_types,
|
|
178
|
+
allow_anonymous=model in self._anonymous_models,
|
|
179
|
+
)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
model_catalog_service = ModelCatalogService(account_service)
|