dsh-plugin-image-workbench 0.0.0-stage → 1.1.6

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.
Files changed (186) hide show
  1. package/LICENSE +21 -0
  2. package/README.en.md +258 -0
  3. package/README.md +260 -2
  4. package/client.js +241 -0
  5. package/cordis.patch.yml +14 -0
  6. package/host.js +692 -0
  7. package/install.mjs +333 -0
  8. package/package.json +58 -4
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -0
  11. package/server/access.py +26 -0
  12. package/server/app.py +1329 -0
  13. package/server/catalog.json +1 -0
  14. package/server/catalog.py +147 -0
  15. package/server/composer.py +391 -0
  16. package/server/drafts.py +158 -0
  17. package/server/history.py +575 -0
  18. package/server/imaging.py +172 -0
  19. package/server/pool.py +1335 -0
  20. package/server/pool_admin.py +921 -0
  21. package/server/pool_service.py +1073 -0
  22. package/server/requirements.txt +6 -0
  23. package/server/smoke_check.py +402 -0
  24. package/server/static/access.html +202 -0
  25. package/server/static/index.html +4725 -0
  26. package/vendor/chatgpt2api/README.md +13 -0
  27. package/vendor/chatgpt2api/SOURCE.json +11 -0
  28. package/vendor/chatgpt2api/runtime/uv.exe +0 -0
  29. package/vendor/chatgpt2api/source/.env.example +82 -0
  30. package/vendor/chatgpt2api/source/.python-version +1 -0
  31. package/vendor/chatgpt2api/source/LICENSE +21 -0
  32. package/vendor/chatgpt2api/source/README.md +391 -0
  33. package/vendor/chatgpt2api/source/VERSION +1 -0
  34. package/vendor/chatgpt2api/source/api/__init__.py +2 -0
  35. package/vendor/chatgpt2api/source/api/accounts.py +537 -0
  36. package/vendor/chatgpt2api/source/api/ai.py +217 -0
  37. package/vendor/chatgpt2api/source/api/app.py +62 -0
  38. package/vendor/chatgpt2api/source/api/errors.py +46 -0
  39. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
  40. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
  41. package/vendor/chatgpt2api/source/api/support.py +134 -0
  42. package/vendor/chatgpt2api/source/api/system.py +365 -0
  43. package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
  44. package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
  45. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
  46. package/vendor/chatgpt2api/source/config.default.json +108 -0
  47. package/vendor/chatgpt2api/source/main.py +9 -0
  48. package/vendor/chatgpt2api/source/pyproject.toml +27 -0
  49. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
  50. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
  51. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
  52. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
  53. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
  54. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
  55. package/vendor/chatgpt2api/source/services/__init__.py +0 -0
  56. package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
  57. package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
  58. package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
  59. package/vendor/chatgpt2api/source/services/config.py +667 -0
  60. package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
  61. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
  62. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
  63. package/vendor/chatgpt2api/source/services/image_service.py +376 -0
  64. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
  65. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
  66. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
  67. package/vendor/chatgpt2api/source/services/log_service.py +337 -0
  68. package/vendor/chatgpt2api/source/services/model_service.py +182 -0
  69. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
  70. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
  71. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
  72. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
  73. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
  74. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
  75. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
  76. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
  77. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
  78. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
  79. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
  80. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
  81. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
  82. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
  83. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
  84. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
  85. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
  86. package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
  87. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
  88. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
  89. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
  90. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
  91. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
  92. package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
  93. package/vendor/chatgpt2api/source/utils/helper.py +469 -0
  94. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
  95. package/vendor/chatgpt2api/source/utils/log.py +110 -0
  96. package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
  97. package/vendor/chatgpt2api/source/utils/pow.py +207 -0
  98. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
  99. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
  100. package/vendor/chatgpt2api/source/uv.lock +904 -0
  101. package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
  102. package/vendor/chatgpt2api/source/web/components.json +24 -0
  103. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
  104. package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
  105. package/vendor/chatgpt2api/source/web/package.json +58 -0
  106. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
  107. package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
  108. package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
  109. package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
  110. package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
  111. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
  112. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
  113. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
  114. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
  115. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
  116. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
  117. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
  118. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
  119. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
  120. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
  121. package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
  122. package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
  123. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
  124. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
  125. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
  126. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
  127. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
  128. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
  129. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
  130. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
  131. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
  133. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
  134. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
  135. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
  136. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
  137. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
  138. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
  139. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
  140. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
  141. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
  142. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
  143. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
  144. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
  145. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
  146. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
  147. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
  148. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
  149. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
  150. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
  151. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
  152. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
  153. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
  154. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
  155. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
  156. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
  157. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
  158. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
  159. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
  160. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
  161. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
  162. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
  163. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
  164. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
  165. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
  166. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
  167. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
  168. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
  169. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
  170. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
  171. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
  172. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
  173. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
  174. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
  175. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
  176. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
  177. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
  178. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
  179. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
  180. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
  181. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
  182. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
  183. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
  184. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
  185. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
  186. 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)