dsh-plugin-image-workbench 1.1.5 → 1.1.7

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 (173) hide show
  1. package/LICENSE +21 -21
  2. package/README.en.md +5 -3
  3. package/README.md +7 -3
  4. package/client.js +241 -241
  5. package/cordis.patch.yml +14 -14
  6. package/host.js +51 -0
  7. package/install.mjs +333 -387
  8. package/package.json +3 -2
  9. package/scripts/fetch-style-images.mjs +104 -0
  10. package/scripts/vendor-snapshot.py +154 -154
  11. package/server/access.py +26 -26
  12. package/server/app.py +1371 -1329
  13. package/server/catalog.py +147 -147
  14. package/server/composer.py +391 -391
  15. package/server/drafts.py +158 -158
  16. package/server/history.py +575 -575
  17. package/server/imaging.py +172 -172
  18. package/server/pool.py +1335 -1335
  19. package/server/pool_admin.py +921 -921
  20. package/server/pool_service.py +1073 -1073
  21. package/server/requirements.txt +6 -6
  22. package/server/smoke_check.py +402 -402
  23. package/server/static/access.html +202 -202
  24. package/server/static/index.html +15 -0
  25. package/vendor/chatgpt2api/source/.env.example +82 -82
  26. package/vendor/chatgpt2api/source/.python-version +1 -1
  27. package/vendor/chatgpt2api/source/LICENSE +21 -21
  28. package/vendor/chatgpt2api/source/README.md +391 -391
  29. package/vendor/chatgpt2api/source/VERSION +1 -1
  30. package/vendor/chatgpt2api/source/api/__init__.py +2 -2
  31. package/vendor/chatgpt2api/source/api/accounts.py +537 -537
  32. package/vendor/chatgpt2api/source/api/ai.py +217 -217
  33. package/vendor/chatgpt2api/source/api/app.py +62 -62
  34. package/vendor/chatgpt2api/source/api/errors.py +46 -46
  35. package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
  36. package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
  37. package/vendor/chatgpt2api/source/api/support.py +134 -134
  38. package/vendor/chatgpt2api/source/api/system.py +365 -365
  39. package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
  40. package/vendor/chatgpt2api/source/config.default.json +108 -108
  41. package/vendor/chatgpt2api/source/main.py +9 -9
  42. package/vendor/chatgpt2api/source/pyproject.toml +27 -27
  43. package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
  44. package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
  45. package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
  46. package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
  47. package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
  48. package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
  49. package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
  50. package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
  51. package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
  52. package/vendor/chatgpt2api/source/services/config.py +667 -667
  53. package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
  54. package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
  55. package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
  56. package/vendor/chatgpt2api/source/services/image_service.py +376 -376
  57. package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
  58. package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
  59. package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
  60. package/vendor/chatgpt2api/source/services/log_service.py +337 -337
  61. package/vendor/chatgpt2api/source/services/model_service.py +182 -182
  62. package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
  63. package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
  64. package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
  65. package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
  66. package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
  67. package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
  68. package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
  69. package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
  70. package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
  71. package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
  72. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
  73. package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
  74. package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
  75. package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
  76. package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
  77. package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
  78. package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
  79. package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
  80. package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
  81. package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
  82. package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
  83. package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
  84. package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
  85. package/vendor/chatgpt2api/source/utils/helper.py +469 -469
  86. package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
  87. package/vendor/chatgpt2api/source/utils/log.py +110 -110
  88. package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
  89. package/vendor/chatgpt2api/source/utils/pow.py +207 -207
  90. package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
  91. package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
  92. package/vendor/chatgpt2api/source/uv.lock +904 -904
  93. package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
  94. package/vendor/chatgpt2api/source/web/components.json +24 -24
  95. package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
  96. package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
  97. package/vendor/chatgpt2api/source/web/package.json +58 -58
  98. package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
  99. package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
  100. package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
  101. package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
  102. package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
  103. package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
  104. package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
  105. package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
  106. package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
  107. package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
  108. package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
  109. package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
  110. package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
  111. package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
  112. package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
  113. package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
  114. package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
  115. package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
  116. package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
  117. package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
  118. package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
  119. package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
  120. package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
  121. package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
  122. package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
  123. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
  124. package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
  125. package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
  126. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
  127. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
  128. package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
  129. package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
  130. package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
  131. package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
  132. package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
  133. package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
  134. package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
  135. package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
  136. package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
  137. package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
  138. package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
  139. package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
  140. package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
  141. package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
  142. package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
  143. package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
  144. package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
  145. package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
  146. package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
  147. package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
  148. package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
  149. package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
  150. package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
  151. package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
  152. package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
  153. package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
  154. package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
  155. package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
  156. package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
  157. package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
  158. package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
  159. package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
  160. package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
  161. package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
  162. package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
  163. package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
  164. package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
  165. package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
  166. package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
  167. package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
  168. package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
  169. package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
  170. package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
  171. package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
  172. package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
  173. package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
@@ -1,667 +1,667 @@
1
- from __future__ import annotations
2
-
3
- import copy
4
- from dataclasses import dataclass
5
- import json
6
- import os
7
- import sys
8
- from pathlib import Path
9
- import time
10
-
11
- from services.storage.base import StorageBackend
12
-
13
- BASE_DIR = Path(__file__).resolve().parents[1]
14
- DATA_DIR = BASE_DIR / "data"
15
- CONFIG_FILE = BASE_DIR / "config.json"
16
- VERSION_FILE = BASE_DIR / "VERSION"
17
- BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
18
-
19
- DEFAULT_BACKUP_INCLUDE = {
20
- "config": True,
21
- "cpa": True,
22
- "sub2api": True,
23
- "logs": True,
24
- "image_tasks": True,
25
- "accounts_snapshot": True,
26
- "auth_keys_snapshot": True,
27
- "images": False,
28
- }
29
-
30
- DEFAULT_IMAGE_STORAGE = {
31
- "enabled": False,
32
- "mode": "local",
33
- "webdav_url": "",
34
- "webdav_username": "",
35
- "webdav_password": "",
36
- "webdav_root_path": "chatgpt2api/images",
37
- "public_base_url": "",
38
- }
39
-
40
- DEFAULT_CHAT_COMPLETION_CACHE = {
41
- "enabled": True,
42
- "ttl_seconds": 60,
43
- "max_entries": 256,
44
- "dedupe_inflight": True,
45
- "stream_cache": True,
46
- "normalize_messages": True,
47
- "drop_adjacent_duplicates": True,
48
- "drop_assistant_history": False,
49
- }
50
-
51
- DEFAULT_PROXY_RUNTIME_USER_AGENT = (
52
- "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
53
- "AppleWebKit/537.36 (KHTML, like Gecko) "
54
- "Chrome/145.0.0.0 Safari/537.36"
55
- )
56
-
57
- DEFAULT_PROXY_RUNTIME = {
58
- "enabled": False,
59
- "egress_mode": "direct",
60
- "proxy_url": "",
61
- "resource_proxy_url": "",
62
- "skip_ssl_verify": False,
63
- "reset_session_status_codes": [403],
64
- "clearance": {
65
- "enabled": False,
66
- "mode": "none",
67
- "cf_cookies": "",
68
- "cf_clearance": "",
69
- "user_agent": DEFAULT_PROXY_RUNTIME_USER_AGENT,
70
- "browser": "chrome",
71
- "flaresolverr_url": "",
72
- "timeout_sec": 60,
73
- "refresh_interval": 3600,
74
- "warm_up_on_start": False,
75
- },
76
- }
77
-
78
- DEFAULT_THIRD_PARTY_APPS = {
79
- "infinite_canvas": {
80
- "enabled": False,
81
- "url": "https://canvas.best",
82
- },
83
- }
84
-
85
-
86
- def _normalize_bool(value: object, default: bool = False) -> bool:
87
- if isinstance(value, str):
88
- lowered = value.strip().lower()
89
- if lowered in {"1", "true", "yes", "on"}:
90
- return True
91
- if lowered in {"0", "false", "no", "off"}:
92
- return False
93
- return default
94
- if value is None:
95
- return default
96
- return bool(value)
97
-
98
-
99
- def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
100
- try:
101
- normalized = int(value)
102
- except (OverflowError, TypeError, ValueError):
103
- normalized = default
104
- return max(minimum, normalized)
105
-
106
-
107
- def _normalize_backup_include(value: object) -> dict[str, bool]:
108
- source = value if isinstance(value, dict) else {}
109
- normalized = dict(DEFAULT_BACKUP_INCLUDE)
110
- for key in normalized:
111
- normalized[key] = _normalize_bool(source.get(key), normalized[key])
112
- return normalized
113
-
114
-
115
- def _normalize_backup_settings(value: object) -> dict[str, object]:
116
- source = value if isinstance(value, dict) else {}
117
- return {
118
- "enabled": _normalize_bool(source.get("enabled"), False),
119
- "provider": "cloudflare_r2",
120
- "account_id": str(source.get("account_id") or "").strip(),
121
- "access_key_id": str(source.get("access_key_id") or "").strip(),
122
- "secret_access_key": str(source.get("secret_access_key") or "").strip(),
123
- "bucket": str(source.get("bucket") or "").strip(),
124
- "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
125
- "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
126
- "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
127
- "encrypt": _normalize_bool(source.get("encrypt"), False),
128
- "passphrase": str(source.get("passphrase") or "").strip(),
129
- "include": _normalize_backup_include(source.get("include")),
130
- }
131
-
132
-
133
- def _normalize_backup_state(value: object) -> dict[str, object]:
134
- source = value if isinstance(value, dict) else {}
135
- return {
136
- "last_started_at": str(source.get("last_started_at") or "").strip() or None,
137
- "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
138
- "last_status": str(source.get("last_status") or "idle").strip() or "idle",
139
- "last_error": str(source.get("last_error") or "").strip() or None,
140
- "last_object_key": str(source.get("last_object_key") or "").strip() or None,
141
- }
142
-
143
-
144
- def _normalize_image_storage_settings(value: object) -> dict[str, object]:
145
- source = value if isinstance(value, dict) else {}
146
- mode = str(source.get("mode") or "local").strip().lower()
147
- if mode not in {"local", "webdav", "both"}:
148
- mode = "local"
149
- enabled = _normalize_bool(source.get("enabled"), False)
150
- if not enabled:
151
- mode = "local"
152
- root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
153
- return {
154
- "enabled": enabled,
155
- "mode": mode,
156
- "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
157
- "webdav_username": str(source.get("webdav_username") or "").strip(),
158
- "webdav_password": str(source.get("webdav_password") or "").strip(),
159
- "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
160
- "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
161
- }
162
-
163
-
164
- def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
165
- source = value if isinstance(value, dict) else {}
166
- return {
167
- "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
168
- "ttl_seconds": _normalize_positive_int(
169
- source.get("ttl_seconds"),
170
- int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
171
- 0,
172
- ),
173
- "max_entries": _normalize_positive_int(
174
- source.get("max_entries"),
175
- int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
176
- 1,
177
- ),
178
- "dedupe_inflight": _normalize_bool(
179
- source.get("dedupe_inflight"),
180
- bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
181
- ),
182
- "stream_cache": _normalize_bool(
183
- source.get("stream_cache"),
184
- bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
185
- ),
186
- "normalize_messages": _normalize_bool(
187
- source.get("normalize_messages"),
188
- bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
189
- ),
190
- "drop_adjacent_duplicates": _normalize_bool(
191
- source.get("drop_adjacent_duplicates"),
192
- bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
193
- ),
194
- "drop_assistant_history": _normalize_bool(
195
- source.get("drop_assistant_history"),
196
- bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
197
- ),
198
- }
199
-
200
-
201
- def _normalize_status_codes(value: object) -> list[int]:
202
- items = value if isinstance(value, list) else DEFAULT_PROXY_RUNTIME["reset_session_status_codes"]
203
- normalized: list[int] = []
204
- for item in items:
205
- if isinstance(item, bool):
206
- continue
207
- try:
208
- status = int(item)
209
- except (OverflowError, TypeError, ValueError):
210
- continue
211
- if 100 <= status <= 599 and status not in normalized:
212
- normalized.append(status)
213
- if not normalized:
214
- return list(DEFAULT_PROXY_RUNTIME["reset_session_status_codes"])
215
- return normalized
216
-
217
-
218
- def _normalize_proxy_runtime_settings(value: object) -> dict[str, object]:
219
- source = value if isinstance(value, dict) else {}
220
- default_clearance = DEFAULT_PROXY_RUNTIME["clearance"]
221
- clearance_source = source.get("clearance") if isinstance(source.get("clearance"), dict) else {}
222
-
223
- egress_mode = str(source.get("egress_mode") or DEFAULT_PROXY_RUNTIME["egress_mode"]).strip().lower()
224
- if egress_mode not in {"direct", "single_proxy"}:
225
- egress_mode = str(DEFAULT_PROXY_RUNTIME["egress_mode"])
226
-
227
- clearance_mode = str(clearance_source.get("mode") or default_clearance["mode"]).strip().lower()
228
- if clearance_mode not in {"none", "manual", "flaresolverr"}:
229
- clearance_mode = str(default_clearance["mode"])
230
-
231
- user_agent = str(clearance_source.get("user_agent") or default_clearance["user_agent"]).strip()
232
- browser = str(clearance_source.get("browser") or default_clearance["browser"]).strip()
233
-
234
- existing_clearance_cookies = str(source.get("_existing_cf_cookies") or "").strip()
235
- existing_cf_clearance = str(source.get("_existing_cf_clearance") or "").strip()
236
- cf_cookies = str(clearance_source.get("cf_cookies") or "").strip()
237
- cf_clearance = str(clearance_source.get("cf_clearance") or "").strip()
238
- if not cf_cookies and _normalize_bool(clearance_source.get("has_cf_cookies"), False):
239
- cf_cookies = existing_clearance_cookies
240
- if not cf_clearance and _normalize_bool(clearance_source.get("has_cf_clearance"), False):
241
- cf_clearance = existing_cf_clearance
242
-
243
- return {
244
- "enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_PROXY_RUNTIME["enabled"])),
245
- "egress_mode": egress_mode,
246
- "proxy_url": str(source.get("proxy_url") or "").strip(),
247
- "resource_proxy_url": str(source.get("resource_proxy_url") or "").strip(),
248
- "skip_ssl_verify": _normalize_bool(
249
- source.get("skip_ssl_verify"),
250
- bool(DEFAULT_PROXY_RUNTIME["skip_ssl_verify"]),
251
- ),
252
- "reset_session_status_codes": _normalize_status_codes(source.get("reset_session_status_codes")),
253
- "clearance": {
254
- "enabled": _normalize_bool(clearance_source.get("enabled"), bool(default_clearance["enabled"])),
255
- "mode": clearance_mode,
256
- "cf_cookies": cf_cookies,
257
- "cf_clearance": cf_clearance,
258
- "user_agent": user_agent or str(default_clearance["user_agent"]),
259
- "browser": browser or str(default_clearance["browser"]),
260
- "flaresolverr_url": str(clearance_source.get("flaresolverr_url") or "").strip(),
261
- "timeout_sec": _normalize_positive_int(
262
- clearance_source.get("timeout_sec"),
263
- int(default_clearance["timeout_sec"]),
264
- 1,
265
- ),
266
- "refresh_interval": _normalize_positive_int(
267
- clearance_source.get("refresh_interval"),
268
- int(default_clearance["refresh_interval"]),
269
- 60,
270
- ),
271
- "warm_up_on_start": _normalize_bool(
272
- clearance_source.get("warm_up_on_start"),
273
- bool(default_clearance["warm_up_on_start"]),
274
- ),
275
- },
276
- }
277
-
278
-
279
- def _normalize_third_party_apps_settings(value: object) -> dict[str, object]:
280
- source = value if isinstance(value, dict) else {}
281
- canvas_source = source.get("infinite_canvas") if isinstance(source.get("infinite_canvas"), dict) else {}
282
- return {
283
- "infinite_canvas": {
284
- "enabled": _normalize_bool(canvas_source.get("enabled"), False),
285
- "url": str(canvas_source.get("url") or DEFAULT_THIRD_PARTY_APPS["infinite_canvas"]["url"]).strip(),
286
- },
287
- }
288
-
289
-
290
- def _validate_image_storage_settings(settings: dict[str, object]) -> None:
291
- if not _normalize_bool(settings.get("enabled"), False):
292
- return
293
- if not str(settings.get("webdav_url") or "").strip():
294
- raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
295
- if not str(settings.get("webdav_password") or "").strip():
296
- raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
297
-
298
-
299
- @dataclass(frozen=True)
300
- class LoadedSettings:
301
- auth_key: str
302
- refresh_account_interval_minute: int
303
-
304
-
305
- def _normalize_auth_key(value: object) -> str:
306
- return str(value or "").strip()
307
-
308
-
309
- def _is_invalid_auth_key(value: object) -> bool:
310
- return _normalize_auth_key(value) == ""
311
-
312
-
313
- def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
314
- if not path.exists():
315
- return {}
316
- if path.is_dir():
317
- print(
318
- f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
319
- file=sys.stderr,
320
- )
321
- return {}
322
- try:
323
- data = json.loads(path.read_text(encoding="utf-8"))
324
- except Exception:
325
- return {}
326
- return data if isinstance(data, dict) else {}
327
-
328
-
329
- def _load_settings() -> LoadedSettings:
330
- DATA_DIR.mkdir(parents=True, exist_ok=True)
331
- raw_config = _read_json_object(CONFIG_FILE, name="config.json")
332
- auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
333
- if _is_invalid_auth_key(auth_key):
334
- raise ValueError(
335
- "❌ auth-key 未设置!\n"
336
- "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
337
- )
338
-
339
- try:
340
- refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
341
- except (TypeError, ValueError):
342
- refresh_interval = 5
343
-
344
- return LoadedSettings(
345
- auth_key=auth_key,
346
- refresh_account_interval_minute=refresh_interval,
347
- )
348
-
349
-
350
- class ConfigStore:
351
- def __init__(self, path: Path):
352
- self.path = path
353
- DATA_DIR.mkdir(parents=True, exist_ok=True)
354
- self.data = self._load()
355
- self._storage_backend: StorageBackend | None = None
356
- if _is_invalid_auth_key(self.auth_key):
357
- raise ValueError(
358
- "❌ auth-key 未设置!\n"
359
- "请按以下任意一种方式解决:\n"
360
- "1. 在 Render 的 Environment 变量中添加:\n"
361
- " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
362
- "2. 或者在 config.json 中填写:\n"
363
- ' "auth-key": "your_real_auth_key"'
364
- )
365
-
366
- def _load(self) -> dict[str, object]:
367
- return _read_json_object(self.path, name="config.json")
368
-
369
- def _save(self) -> None:
370
- self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
371
-
372
- @property
373
- def auth_key(self) -> str:
374
- return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
375
-
376
- @property
377
- def accounts_file(self) -> Path:
378
- return DATA_DIR / "accounts.json"
379
-
380
- @property
381
- def refresh_account_interval_minute(self) -> int:
382
- try:
383
- return int(self.data.get("refresh_account_interval_minute", 5))
384
- except (TypeError, ValueError):
385
- return 5
386
-
387
- @property
388
- def image_retention_days(self) -> int:
389
- try:
390
- return max(1, int(self.data.get("image_retention_days", 30)))
391
- except (TypeError, ValueError):
392
- return 30
393
-
394
- @property
395
- def image_poll_timeout_secs(self) -> int:
396
- try:
397
- return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
398
- except (TypeError, ValueError):
399
- return 120
400
-
401
- @property
402
- def image_poll_interval_secs(self) -> float:
403
- try:
404
- return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
405
- except (TypeError, ValueError):
406
- return 10.0
407
-
408
- @property
409
- def image_poll_initial_wait_secs(self) -> float:
410
- """Image generation upstream takes ~30s; polling immediately wastes requests
411
- and trips a transient 429. Default 10s gives the conversation document time
412
- to commit before the first poll."""
413
- try:
414
- return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
415
- except (TypeError, ValueError):
416
- return 10.0
417
-
418
- @property
419
- def image_account_concurrency(self) -> int:
420
- try:
421
- return max(1, int(self.data.get("image_account_concurrency", 3)))
422
- except (TypeError, ValueError):
423
- return 3
424
-
425
- @property
426
- def image_parallel_generation(self) -> bool:
427
- value = self.data.get("image_parallel_generation", True)
428
- if isinstance(value, str):
429
- return value.strip().lower() in {"1", "true", "yes", "on"}
430
- return bool(value)
431
-
432
- @property
433
- def image_settle_enabled(self) -> bool:
434
- """图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
435
- value = self.data.get("image_settle_enabled", True)
436
- if isinstance(value, str):
437
- return value.strip().lower() in {"1", "true", "yes", "on"}
438
- return bool(value)
439
-
440
- @property
441
- def image_check_before_hit_enabled(self) -> bool:
442
- """先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
443
- value = self.data.get("image_check_before_hit_enabled", True)
444
- if isinstance(value, str):
445
- return value.strip().lower() in {"1", "true", "yes", "on"}
446
- return bool(value)
447
-
448
- @property
449
- def image_remove_conversation_after_result(self) -> bool:
450
- """出图成功后异步隐藏 ChatGPT 本地对话记录。"""
451
- value = self.data.get("image_remove_conversation_after_result", False)
452
- if isinstance(value, str):
453
- return value.strip().lower() in {"1", "true", "yes", "on"}
454
- return bool(value)
455
-
456
- @property
457
- def image_remove_conversation_always(self) -> bool:
458
- """无论是否出图,画图请求结束后都异步隐藏 ChatGPT 本地对话记录。"""
459
- return _normalize_bool(self.data.get("image_remove_conversation_always"), False)
460
-
461
- @property
462
- def image_settle_secs(self) -> float:
463
- """二次确认等待时间(秒)。"""
464
- try:
465
- return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
466
- except (TypeError, ValueError):
467
- return 2.0
468
-
469
- @property
470
- def auto_remove_invalid_accounts(self) -> bool:
471
- value = self.data.get("auto_remove_invalid_accounts", False)
472
- if isinstance(value, str):
473
- return value.strip().lower() in {"1", "true", "yes", "on"}
474
- return bool(value)
475
-
476
- @property
477
- def auto_remove_rate_limited_accounts(self) -> bool:
478
- value = self.data.get("auto_remove_rate_limited_accounts", False)
479
- if isinstance(value, str):
480
- return value.strip().lower() in {"1", "true", "yes", "on"}
481
- return bool(value)
482
-
483
- @property
484
- def auto_relogin_after_refresh(self) -> bool:
485
- value = self.data.get("auto_relogin_after_refresh", False)
486
- if isinstance(value, str):
487
- return value.strip().lower() in {"1", "true", "yes", "on"}
488
- return bool(value)
489
-
490
- @property
491
- def log_levels(self) -> list[str]:
492
- levels = self.data.get("log_levels")
493
- if not isinstance(levels, list):
494
- return []
495
- allowed = {"debug", "info", "warning", "error"}
496
- return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
497
-
498
- @property
499
- def sensitive_words(self) -> list[str]:
500
- words = self.data.get("sensitive_words")
501
- return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
502
-
503
- @property
504
- def ai_review(self) -> dict[str, object]:
505
- value = self.data.get("ai_review")
506
- return value if isinstance(value, dict) else {}
507
-
508
- @property
509
- def global_system_prompt(self) -> str:
510
- return str(self.data.get("global_system_prompt") or "").strip()
511
-
512
- @property
513
- def default_upstream_model_name(self) -> str:
514
- return str(self.data.get("default_upstream_model_name") or "gpt-5-5").strip()
515
-
516
- @property
517
- def default_thinking_effort(self) -> str:
518
- value = str(self.data.get("default_thinking_effort") or "auto").strip().lower()
519
- return value if value in {"auto", "standard", "extended", "max"} else "auto"
520
-
521
- @property
522
- def images_dir(self) -> Path:
523
- path = DATA_DIR / "images"
524
- path.mkdir(parents=True, exist_ok=True)
525
- return path
526
-
527
- @property
528
- def image_thumbnails_dir(self) -> Path:
529
- path = DATA_DIR / "image_thumbnails"
530
- path.mkdir(parents=True, exist_ok=True)
531
- return path
532
-
533
- def cleanup_old_images(self) -> int:
534
- cutoff = time.time() - self.image_retention_days * 86400
535
- removed = 0
536
- for path in self.images_dir.rglob("*"):
537
- if path.is_file() and path.stat().st_mtime < cutoff:
538
- path.unlink()
539
- removed += 1
540
- for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
541
- try:
542
- path.rmdir()
543
- except OSError:
544
- pass
545
- return removed
546
-
547
- @property
548
- def base_url(self) -> str:
549
- return str(
550
- os.getenv("CHATGPT2API_BASE_URL")
551
- or self.data.get("base_url")
552
- or ""
553
- ).strip().rstrip("/")
554
-
555
- @property
556
- def app_version(self) -> str:
557
- try:
558
- value = VERSION_FILE.read_text(encoding="utf-8").strip()
559
- except FileNotFoundError:
560
- return "0.0.0"
561
- return value or "0.0.0"
562
-
563
- def get(self) -> dict[str, object]:
564
- data = dict(self.data)
565
- data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
566
- data["image_retention_days"] = self.image_retention_days
567
- data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
568
- data["image_poll_interval_secs"] = self.image_poll_interval_secs
569
- data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
570
- data["image_account_concurrency"] = self.image_account_concurrency
571
- data["image_parallel_generation"] = self.image_parallel_generation
572
- data["image_remove_conversation_after_result"] = self.image_remove_conversation_after_result
573
- data["image_remove_conversation_always"] = self.image_remove_conversation_always
574
- data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
575
- data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
576
- data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
577
- data["log_levels"] = self.log_levels
578
- data["sensitive_words"] = self.sensitive_words
579
- data["ai_review"] = self.ai_review
580
- data["global_system_prompt"] = self.global_system_prompt
581
- data["default_upstream_model_name"] = self.default_upstream_model_name
582
- data["default_thinking_effort"] = self.default_thinking_effort
583
- data["backup"] = self.get_backup_settings()
584
- data["image_storage"] = self.get_image_storage_settings()
585
- data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
586
- data["proxy_runtime"] = self.get_public_proxy_runtime_settings()
587
- data["third_party_apps"] = self.get_third_party_apps_settings()
588
- data.pop("auth-key", None)
589
- return data
590
-
591
- def get_proxy_settings(self) -> str:
592
- return str(self.data.get("proxy") or "").strip()
593
-
594
- def get_proxy_runtime_settings(self) -> dict[str, object]:
595
- return _normalize_proxy_runtime_settings(self.data.get("proxy_runtime"))
596
-
597
- def get_public_proxy_runtime_settings(self) -> dict[str, object]:
598
- runtime = copy.deepcopy(self.get_proxy_runtime_settings())
599
- clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
600
- if isinstance(clearance, dict):
601
- cf_cookies = str(clearance.get("cf_cookies") or "").strip()
602
- cf_clearance = str(clearance.get("cf_clearance") or "").strip()
603
- clearance["cf_cookies"] = ""
604
- clearance["cf_clearance"] = ""
605
- clearance["has_cf_cookies"] = bool(cf_cookies)
606
- clearance["has_cf_clearance"] = bool(cf_clearance)
607
- return runtime
608
-
609
- def get_third_party_apps_settings(self) -> dict[str, object]:
610
- return _normalize_third_party_apps_settings(self.data.get("third_party_apps"))
611
-
612
- def update(self, data: dict[str, object]) -> dict[str, object]:
613
- next_data = dict(self.data)
614
- next_data.update(dict(data or {}))
615
- if "backup" in next_data:
616
- next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
617
- if "image_storage" in next_data:
618
- next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
619
- _validate_image_storage_settings(next_data["image_storage"])
620
- if "chat_completion_cache" in next_data:
621
- next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
622
- next_data.get("chat_completion_cache")
623
- )
624
- if "third_party_apps" in next_data:
625
- next_data["third_party_apps"] = _normalize_third_party_apps_settings(next_data.get("third_party_apps"))
626
- if "proxy_runtime" in next_data:
627
- incoming_runtime = next_data.get("proxy_runtime")
628
- if isinstance(incoming_runtime, dict):
629
- previous_clearance = self.get_proxy_runtime_settings().get("clearance")
630
- if isinstance(previous_clearance, dict):
631
- incoming_runtime = dict(incoming_runtime)
632
- incoming_runtime["_existing_cf_cookies"] = previous_clearance.get("cf_cookies")
633
- incoming_runtime["_existing_cf_clearance"] = previous_clearance.get("cf_clearance")
634
- next_data["proxy_runtime"] = _normalize_proxy_runtime_settings(incoming_runtime)
635
- next_data.pop("backup_state", None)
636
- self.data = next_data
637
- self._save()
638
- return self.get()
639
-
640
- def get_backup_settings(self) -> dict[str, object]:
641
- return _normalize_backup_settings(self.data.get("backup"))
642
-
643
- def get_image_storage_settings(self) -> dict[str, object]:
644
- return _normalize_image_storage_settings(self.data.get("image_storage"))
645
-
646
- def get_chat_completion_cache_settings(self) -> dict[str, object]:
647
- return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
648
-
649
- def get_storage_backend(self) -> StorageBackend:
650
- """获取存储后端实例(单例)"""
651
- if self._storage_backend is None:
652
- from services.storage.factory import create_storage_backend
653
- self._storage_backend = create_storage_backend(DATA_DIR)
654
- return self._storage_backend
655
-
656
-
657
- def load_backup_state() -> dict[str, object]:
658
- return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
659
-
660
-
661
- def save_backup_state(state: dict[str, object]) -> dict[str, object]:
662
- normalized = _normalize_backup_state(state)
663
- BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
664
- return normalized
665
-
666
-
667
- config = ConfigStore(CONFIG_FILE)
1
+ from __future__ import annotations
2
+
3
+ import copy
4
+ from dataclasses import dataclass
5
+ import json
6
+ import os
7
+ import sys
8
+ from pathlib import Path
9
+ import time
10
+
11
+ from services.storage.base import StorageBackend
12
+
13
+ BASE_DIR = Path(__file__).resolve().parents[1]
14
+ DATA_DIR = BASE_DIR / "data"
15
+ CONFIG_FILE = BASE_DIR / "config.json"
16
+ VERSION_FILE = BASE_DIR / "VERSION"
17
+ BACKUP_STATE_FILE = DATA_DIR / "backup_state.json"
18
+
19
+ DEFAULT_BACKUP_INCLUDE = {
20
+ "config": True,
21
+ "cpa": True,
22
+ "sub2api": True,
23
+ "logs": True,
24
+ "image_tasks": True,
25
+ "accounts_snapshot": True,
26
+ "auth_keys_snapshot": True,
27
+ "images": False,
28
+ }
29
+
30
+ DEFAULT_IMAGE_STORAGE = {
31
+ "enabled": False,
32
+ "mode": "local",
33
+ "webdav_url": "",
34
+ "webdav_username": "",
35
+ "webdav_password": "",
36
+ "webdav_root_path": "chatgpt2api/images",
37
+ "public_base_url": "",
38
+ }
39
+
40
+ DEFAULT_CHAT_COMPLETION_CACHE = {
41
+ "enabled": True,
42
+ "ttl_seconds": 60,
43
+ "max_entries": 256,
44
+ "dedupe_inflight": True,
45
+ "stream_cache": True,
46
+ "normalize_messages": True,
47
+ "drop_adjacent_duplicates": True,
48
+ "drop_assistant_history": False,
49
+ }
50
+
51
+ DEFAULT_PROXY_RUNTIME_USER_AGENT = (
52
+ "Mozilla/5.0 (Windows NT 10.0; Win64; x64) "
53
+ "AppleWebKit/537.36 (KHTML, like Gecko) "
54
+ "Chrome/145.0.0.0 Safari/537.36"
55
+ )
56
+
57
+ DEFAULT_PROXY_RUNTIME = {
58
+ "enabled": False,
59
+ "egress_mode": "direct",
60
+ "proxy_url": "",
61
+ "resource_proxy_url": "",
62
+ "skip_ssl_verify": False,
63
+ "reset_session_status_codes": [403],
64
+ "clearance": {
65
+ "enabled": False,
66
+ "mode": "none",
67
+ "cf_cookies": "",
68
+ "cf_clearance": "",
69
+ "user_agent": DEFAULT_PROXY_RUNTIME_USER_AGENT,
70
+ "browser": "chrome",
71
+ "flaresolverr_url": "",
72
+ "timeout_sec": 60,
73
+ "refresh_interval": 3600,
74
+ "warm_up_on_start": False,
75
+ },
76
+ }
77
+
78
+ DEFAULT_THIRD_PARTY_APPS = {
79
+ "infinite_canvas": {
80
+ "enabled": False,
81
+ "url": "https://canvas.best",
82
+ },
83
+ }
84
+
85
+
86
+ def _normalize_bool(value: object, default: bool = False) -> bool:
87
+ if isinstance(value, str):
88
+ lowered = value.strip().lower()
89
+ if lowered in {"1", "true", "yes", "on"}:
90
+ return True
91
+ if lowered in {"0", "false", "no", "off"}:
92
+ return False
93
+ return default
94
+ if value is None:
95
+ return default
96
+ return bool(value)
97
+
98
+
99
+ def _normalize_positive_int(value: object, default: int, minimum: int = 0) -> int:
100
+ try:
101
+ normalized = int(value)
102
+ except (OverflowError, TypeError, ValueError):
103
+ normalized = default
104
+ return max(minimum, normalized)
105
+
106
+
107
+ def _normalize_backup_include(value: object) -> dict[str, bool]:
108
+ source = value if isinstance(value, dict) else {}
109
+ normalized = dict(DEFAULT_BACKUP_INCLUDE)
110
+ for key in normalized:
111
+ normalized[key] = _normalize_bool(source.get(key), normalized[key])
112
+ return normalized
113
+
114
+
115
+ def _normalize_backup_settings(value: object) -> dict[str, object]:
116
+ source = value if isinstance(value, dict) else {}
117
+ return {
118
+ "enabled": _normalize_bool(source.get("enabled"), False),
119
+ "provider": "cloudflare_r2",
120
+ "account_id": str(source.get("account_id") or "").strip(),
121
+ "access_key_id": str(source.get("access_key_id") or "").strip(),
122
+ "secret_access_key": str(source.get("secret_access_key") or "").strip(),
123
+ "bucket": str(source.get("bucket") or "").strip(),
124
+ "prefix": str(source.get("prefix") or "backups").strip().strip("/") or "backups",
125
+ "interval_minutes": _normalize_positive_int(source.get("interval_minutes"), 360, 1),
126
+ "rotation_keep": _normalize_positive_int(source.get("rotation_keep"), 10, 0),
127
+ "encrypt": _normalize_bool(source.get("encrypt"), False),
128
+ "passphrase": str(source.get("passphrase") or "").strip(),
129
+ "include": _normalize_backup_include(source.get("include")),
130
+ }
131
+
132
+
133
+ def _normalize_backup_state(value: object) -> dict[str, object]:
134
+ source = value if isinstance(value, dict) else {}
135
+ return {
136
+ "last_started_at": str(source.get("last_started_at") or "").strip() or None,
137
+ "last_finished_at": str(source.get("last_finished_at") or "").strip() or None,
138
+ "last_status": str(source.get("last_status") or "idle").strip() or "idle",
139
+ "last_error": str(source.get("last_error") or "").strip() or None,
140
+ "last_object_key": str(source.get("last_object_key") or "").strip() or None,
141
+ }
142
+
143
+
144
+ def _normalize_image_storage_settings(value: object) -> dict[str, object]:
145
+ source = value if isinstance(value, dict) else {}
146
+ mode = str(source.get("mode") or "local").strip().lower()
147
+ if mode not in {"local", "webdav", "both"}:
148
+ mode = "local"
149
+ enabled = _normalize_bool(source.get("enabled"), False)
150
+ if not enabled:
151
+ mode = "local"
152
+ root_path = str(source.get("webdav_root_path") or DEFAULT_IMAGE_STORAGE["webdav_root_path"]).strip().strip("/")
153
+ return {
154
+ "enabled": enabled,
155
+ "mode": mode,
156
+ "webdav_url": str(source.get("webdav_url") or "").strip().rstrip("/"),
157
+ "webdav_username": str(source.get("webdav_username") or "").strip(),
158
+ "webdav_password": str(source.get("webdav_password") or "").strip(),
159
+ "webdav_root_path": root_path or str(DEFAULT_IMAGE_STORAGE["webdav_root_path"]),
160
+ "public_base_url": str(source.get("public_base_url") or "").strip().rstrip("/"),
161
+ }
162
+
163
+
164
+ def _normalize_chat_completion_cache_settings(value: object) -> dict[str, object]:
165
+ source = value if isinstance(value, dict) else {}
166
+ return {
167
+ "enabled": _normalize_bool(source.get("enabled"), DEFAULT_CHAT_COMPLETION_CACHE["enabled"]),
168
+ "ttl_seconds": _normalize_positive_int(
169
+ source.get("ttl_seconds"),
170
+ int(DEFAULT_CHAT_COMPLETION_CACHE["ttl_seconds"]),
171
+ 0,
172
+ ),
173
+ "max_entries": _normalize_positive_int(
174
+ source.get("max_entries"),
175
+ int(DEFAULT_CHAT_COMPLETION_CACHE["max_entries"]),
176
+ 1,
177
+ ),
178
+ "dedupe_inflight": _normalize_bool(
179
+ source.get("dedupe_inflight"),
180
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["dedupe_inflight"]),
181
+ ),
182
+ "stream_cache": _normalize_bool(
183
+ source.get("stream_cache"),
184
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["stream_cache"]),
185
+ ),
186
+ "normalize_messages": _normalize_bool(
187
+ source.get("normalize_messages"),
188
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["normalize_messages"]),
189
+ ),
190
+ "drop_adjacent_duplicates": _normalize_bool(
191
+ source.get("drop_adjacent_duplicates"),
192
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_adjacent_duplicates"]),
193
+ ),
194
+ "drop_assistant_history": _normalize_bool(
195
+ source.get("drop_assistant_history"),
196
+ bool(DEFAULT_CHAT_COMPLETION_CACHE["drop_assistant_history"]),
197
+ ),
198
+ }
199
+
200
+
201
+ def _normalize_status_codes(value: object) -> list[int]:
202
+ items = value if isinstance(value, list) else DEFAULT_PROXY_RUNTIME["reset_session_status_codes"]
203
+ normalized: list[int] = []
204
+ for item in items:
205
+ if isinstance(item, bool):
206
+ continue
207
+ try:
208
+ status = int(item)
209
+ except (OverflowError, TypeError, ValueError):
210
+ continue
211
+ if 100 <= status <= 599 and status not in normalized:
212
+ normalized.append(status)
213
+ if not normalized:
214
+ return list(DEFAULT_PROXY_RUNTIME["reset_session_status_codes"])
215
+ return normalized
216
+
217
+
218
+ def _normalize_proxy_runtime_settings(value: object) -> dict[str, object]:
219
+ source = value if isinstance(value, dict) else {}
220
+ default_clearance = DEFAULT_PROXY_RUNTIME["clearance"]
221
+ clearance_source = source.get("clearance") if isinstance(source.get("clearance"), dict) else {}
222
+
223
+ egress_mode = str(source.get("egress_mode") or DEFAULT_PROXY_RUNTIME["egress_mode"]).strip().lower()
224
+ if egress_mode not in {"direct", "single_proxy"}:
225
+ egress_mode = str(DEFAULT_PROXY_RUNTIME["egress_mode"])
226
+
227
+ clearance_mode = str(clearance_source.get("mode") or default_clearance["mode"]).strip().lower()
228
+ if clearance_mode not in {"none", "manual", "flaresolverr"}:
229
+ clearance_mode = str(default_clearance["mode"])
230
+
231
+ user_agent = str(clearance_source.get("user_agent") or default_clearance["user_agent"]).strip()
232
+ browser = str(clearance_source.get("browser") or default_clearance["browser"]).strip()
233
+
234
+ existing_clearance_cookies = str(source.get("_existing_cf_cookies") or "").strip()
235
+ existing_cf_clearance = str(source.get("_existing_cf_clearance") or "").strip()
236
+ cf_cookies = str(clearance_source.get("cf_cookies") or "").strip()
237
+ cf_clearance = str(clearance_source.get("cf_clearance") or "").strip()
238
+ if not cf_cookies and _normalize_bool(clearance_source.get("has_cf_cookies"), False):
239
+ cf_cookies = existing_clearance_cookies
240
+ if not cf_clearance and _normalize_bool(clearance_source.get("has_cf_clearance"), False):
241
+ cf_clearance = existing_cf_clearance
242
+
243
+ return {
244
+ "enabled": _normalize_bool(source.get("enabled"), bool(DEFAULT_PROXY_RUNTIME["enabled"])),
245
+ "egress_mode": egress_mode,
246
+ "proxy_url": str(source.get("proxy_url") or "").strip(),
247
+ "resource_proxy_url": str(source.get("resource_proxy_url") or "").strip(),
248
+ "skip_ssl_verify": _normalize_bool(
249
+ source.get("skip_ssl_verify"),
250
+ bool(DEFAULT_PROXY_RUNTIME["skip_ssl_verify"]),
251
+ ),
252
+ "reset_session_status_codes": _normalize_status_codes(source.get("reset_session_status_codes")),
253
+ "clearance": {
254
+ "enabled": _normalize_bool(clearance_source.get("enabled"), bool(default_clearance["enabled"])),
255
+ "mode": clearance_mode,
256
+ "cf_cookies": cf_cookies,
257
+ "cf_clearance": cf_clearance,
258
+ "user_agent": user_agent or str(default_clearance["user_agent"]),
259
+ "browser": browser or str(default_clearance["browser"]),
260
+ "flaresolverr_url": str(clearance_source.get("flaresolverr_url") or "").strip(),
261
+ "timeout_sec": _normalize_positive_int(
262
+ clearance_source.get("timeout_sec"),
263
+ int(default_clearance["timeout_sec"]),
264
+ 1,
265
+ ),
266
+ "refresh_interval": _normalize_positive_int(
267
+ clearance_source.get("refresh_interval"),
268
+ int(default_clearance["refresh_interval"]),
269
+ 60,
270
+ ),
271
+ "warm_up_on_start": _normalize_bool(
272
+ clearance_source.get("warm_up_on_start"),
273
+ bool(default_clearance["warm_up_on_start"]),
274
+ ),
275
+ },
276
+ }
277
+
278
+
279
+ def _normalize_third_party_apps_settings(value: object) -> dict[str, object]:
280
+ source = value if isinstance(value, dict) else {}
281
+ canvas_source = source.get("infinite_canvas") if isinstance(source.get("infinite_canvas"), dict) else {}
282
+ return {
283
+ "infinite_canvas": {
284
+ "enabled": _normalize_bool(canvas_source.get("enabled"), False),
285
+ "url": str(canvas_source.get("url") or DEFAULT_THIRD_PARTY_APPS["infinite_canvas"]["url"]).strip(),
286
+ },
287
+ }
288
+
289
+
290
+ def _validate_image_storage_settings(settings: dict[str, object]) -> None:
291
+ if not _normalize_bool(settings.get("enabled"), False):
292
+ return
293
+ if not str(settings.get("webdav_url") or "").strip():
294
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV URL")
295
+ if not str(settings.get("webdav_password") or "").strip():
296
+ raise ValueError("启用 WebDAV 图片存储后必须填写 WebDAV 密码")
297
+
298
+
299
+ @dataclass(frozen=True)
300
+ class LoadedSettings:
301
+ auth_key: str
302
+ refresh_account_interval_minute: int
303
+
304
+
305
+ def _normalize_auth_key(value: object) -> str:
306
+ return str(value or "").strip()
307
+
308
+
309
+ def _is_invalid_auth_key(value: object) -> bool:
310
+ return _normalize_auth_key(value) == ""
311
+
312
+
313
+ def _read_json_object(path: Path, *, name: str) -> dict[str, object]:
314
+ if not path.exists():
315
+ return {}
316
+ if path.is_dir():
317
+ print(
318
+ f"Warning: {name} at '{path}' is a directory, ignoring it and falling back to other configuration sources.",
319
+ file=sys.stderr,
320
+ )
321
+ return {}
322
+ try:
323
+ data = json.loads(path.read_text(encoding="utf-8"))
324
+ except Exception:
325
+ return {}
326
+ return data if isinstance(data, dict) else {}
327
+
328
+
329
+ def _load_settings() -> LoadedSettings:
330
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
331
+ raw_config = _read_json_object(CONFIG_FILE, name="config.json")
332
+ auth_key = _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or raw_config.get("auth-key"))
333
+ if _is_invalid_auth_key(auth_key):
334
+ raise ValueError(
335
+ "❌ auth-key 未设置!\n"
336
+ "请在环境变量 CHATGPT2API_AUTH_KEY 中设置,或者在 config.json 中填写 auth-key。"
337
+ )
338
+
339
+ try:
340
+ refresh_interval = int(raw_config.get("refresh_account_interval_minute", 5))
341
+ except (TypeError, ValueError):
342
+ refresh_interval = 5
343
+
344
+ return LoadedSettings(
345
+ auth_key=auth_key,
346
+ refresh_account_interval_minute=refresh_interval,
347
+ )
348
+
349
+
350
+ class ConfigStore:
351
+ def __init__(self, path: Path):
352
+ self.path = path
353
+ DATA_DIR.mkdir(parents=True, exist_ok=True)
354
+ self.data = self._load()
355
+ self._storage_backend: StorageBackend | None = None
356
+ if _is_invalid_auth_key(self.auth_key):
357
+ raise ValueError(
358
+ "❌ auth-key 未设置!\n"
359
+ "请按以下任意一种方式解决:\n"
360
+ "1. 在 Render 的 Environment 变量中添加:\n"
361
+ " CHATGPT2API_AUTH_KEY = your_real_auth_key\n"
362
+ "2. 或者在 config.json 中填写:\n"
363
+ ' "auth-key": "your_real_auth_key"'
364
+ )
365
+
366
+ def _load(self) -> dict[str, object]:
367
+ return _read_json_object(self.path, name="config.json")
368
+
369
+ def _save(self) -> None:
370
+ self.path.write_text(json.dumps(self.data, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
371
+
372
+ @property
373
+ def auth_key(self) -> str:
374
+ return _normalize_auth_key(os.getenv("CHATGPT2API_AUTH_KEY") or self.data.get("auth-key"))
375
+
376
+ @property
377
+ def accounts_file(self) -> Path:
378
+ return DATA_DIR / "accounts.json"
379
+
380
+ @property
381
+ def refresh_account_interval_minute(self) -> int:
382
+ try:
383
+ return int(self.data.get("refresh_account_interval_minute", 5))
384
+ except (TypeError, ValueError):
385
+ return 5
386
+
387
+ @property
388
+ def image_retention_days(self) -> int:
389
+ try:
390
+ return max(1, int(self.data.get("image_retention_days", 30)))
391
+ except (TypeError, ValueError):
392
+ return 30
393
+
394
+ @property
395
+ def image_poll_timeout_secs(self) -> int:
396
+ try:
397
+ return max(1, int(self.data.get("image_poll_timeout_secs", 120)))
398
+ except (TypeError, ValueError):
399
+ return 120
400
+
401
+ @property
402
+ def image_poll_interval_secs(self) -> float:
403
+ try:
404
+ return max(0.5, float(self.data.get("image_poll_interval_secs", 10.0)))
405
+ except (TypeError, ValueError):
406
+ return 10.0
407
+
408
+ @property
409
+ def image_poll_initial_wait_secs(self) -> float:
410
+ """Image generation upstream takes ~30s; polling immediately wastes requests
411
+ and trips a transient 429. Default 10s gives the conversation document time
412
+ to commit before the first poll."""
413
+ try:
414
+ return max(0.0, float(self.data.get("image_poll_initial_wait_secs", 10.0)))
415
+ except (TypeError, ValueError):
416
+ return 10.0
417
+
418
+ @property
419
+ def image_account_concurrency(self) -> int:
420
+ try:
421
+ return max(1, int(self.data.get("image_account_concurrency", 3)))
422
+ except (TypeError, ValueError):
423
+ return 3
424
+
425
+ @property
426
+ def image_parallel_generation(self) -> bool:
427
+ value = self.data.get("image_parallel_generation", True)
428
+ if isinstance(value, str):
429
+ return value.strip().lower() in {"1", "true", "yes", "on"}
430
+ return bool(value)
431
+
432
+ @property
433
+ def image_settle_enabled(self) -> bool:
434
+ """图片二次确认机制:找到 file_ids 后等待一段时间再次确认。"""
435
+ value = self.data.get("image_settle_enabled", True)
436
+ if isinstance(value, str):
437
+ return value.strip().lower() in {"1", "true", "yes", "on"}
438
+ return bool(value)
439
+
440
+ @property
441
+ def image_check_before_hit_enabled(self) -> bool:
442
+ """先check再hit:通过轮询确认 file_ids 存在后再返回,而非仅依赖 SSE 事件。"""
443
+ value = self.data.get("image_check_before_hit_enabled", True)
444
+ if isinstance(value, str):
445
+ return value.strip().lower() in {"1", "true", "yes", "on"}
446
+ return bool(value)
447
+
448
+ @property
449
+ def image_remove_conversation_after_result(self) -> bool:
450
+ """出图成功后异步隐藏 ChatGPT 本地对话记录。"""
451
+ value = self.data.get("image_remove_conversation_after_result", False)
452
+ if isinstance(value, str):
453
+ return value.strip().lower() in {"1", "true", "yes", "on"}
454
+ return bool(value)
455
+
456
+ @property
457
+ def image_remove_conversation_always(self) -> bool:
458
+ """无论是否出图,画图请求结束后都异步隐藏 ChatGPT 本地对话记录。"""
459
+ return _normalize_bool(self.data.get("image_remove_conversation_always"), False)
460
+
461
+ @property
462
+ def image_settle_secs(self) -> float:
463
+ """二次确认等待时间(秒)。"""
464
+ try:
465
+ return max(0.5, float(self.data.get("image_settle_secs", 2.0)))
466
+ except (TypeError, ValueError):
467
+ return 2.0
468
+
469
+ @property
470
+ def auto_remove_invalid_accounts(self) -> bool:
471
+ value = self.data.get("auto_remove_invalid_accounts", False)
472
+ if isinstance(value, str):
473
+ return value.strip().lower() in {"1", "true", "yes", "on"}
474
+ return bool(value)
475
+
476
+ @property
477
+ def auto_remove_rate_limited_accounts(self) -> bool:
478
+ value = self.data.get("auto_remove_rate_limited_accounts", False)
479
+ if isinstance(value, str):
480
+ return value.strip().lower() in {"1", "true", "yes", "on"}
481
+ return bool(value)
482
+
483
+ @property
484
+ def auto_relogin_after_refresh(self) -> bool:
485
+ value = self.data.get("auto_relogin_after_refresh", False)
486
+ if isinstance(value, str):
487
+ return value.strip().lower() in {"1", "true", "yes", "on"}
488
+ return bool(value)
489
+
490
+ @property
491
+ def log_levels(self) -> list[str]:
492
+ levels = self.data.get("log_levels")
493
+ if not isinstance(levels, list):
494
+ return []
495
+ allowed = {"debug", "info", "warning", "error"}
496
+ return [level for item in levels if (level := str(item or "").strip().lower()) in allowed]
497
+
498
+ @property
499
+ def sensitive_words(self) -> list[str]:
500
+ words = self.data.get("sensitive_words")
501
+ return [word for item in words if (word := str(item or "").strip())] if isinstance(words, list) else []
502
+
503
+ @property
504
+ def ai_review(self) -> dict[str, object]:
505
+ value = self.data.get("ai_review")
506
+ return value if isinstance(value, dict) else {}
507
+
508
+ @property
509
+ def global_system_prompt(self) -> str:
510
+ return str(self.data.get("global_system_prompt") or "").strip()
511
+
512
+ @property
513
+ def default_upstream_model_name(self) -> str:
514
+ return str(self.data.get("default_upstream_model_name") or "gpt-5-5").strip()
515
+
516
+ @property
517
+ def default_thinking_effort(self) -> str:
518
+ value = str(self.data.get("default_thinking_effort") or "auto").strip().lower()
519
+ return value if value in {"auto", "standard", "extended", "max"} else "auto"
520
+
521
+ @property
522
+ def images_dir(self) -> Path:
523
+ path = DATA_DIR / "images"
524
+ path.mkdir(parents=True, exist_ok=True)
525
+ return path
526
+
527
+ @property
528
+ def image_thumbnails_dir(self) -> Path:
529
+ path = DATA_DIR / "image_thumbnails"
530
+ path.mkdir(parents=True, exist_ok=True)
531
+ return path
532
+
533
+ def cleanup_old_images(self) -> int:
534
+ cutoff = time.time() - self.image_retention_days * 86400
535
+ removed = 0
536
+ for path in self.images_dir.rglob("*"):
537
+ if path.is_file() and path.stat().st_mtime < cutoff:
538
+ path.unlink()
539
+ removed += 1
540
+ for path in sorted((p for p in self.images_dir.rglob("*") if p.is_dir()), key=lambda p: len(p.parts), reverse=True):
541
+ try:
542
+ path.rmdir()
543
+ except OSError:
544
+ pass
545
+ return removed
546
+
547
+ @property
548
+ def base_url(self) -> str:
549
+ return str(
550
+ os.getenv("CHATGPT2API_BASE_URL")
551
+ or self.data.get("base_url")
552
+ or ""
553
+ ).strip().rstrip("/")
554
+
555
+ @property
556
+ def app_version(self) -> str:
557
+ try:
558
+ value = VERSION_FILE.read_text(encoding="utf-8").strip()
559
+ except FileNotFoundError:
560
+ return "0.0.0"
561
+ return value or "0.0.0"
562
+
563
+ def get(self) -> dict[str, object]:
564
+ data = dict(self.data)
565
+ data["refresh_account_interval_minute"] = self.refresh_account_interval_minute
566
+ data["image_retention_days"] = self.image_retention_days
567
+ data["image_poll_timeout_secs"] = self.image_poll_timeout_secs
568
+ data["image_poll_interval_secs"] = self.image_poll_interval_secs
569
+ data["image_poll_initial_wait_secs"] = self.image_poll_initial_wait_secs
570
+ data["image_account_concurrency"] = self.image_account_concurrency
571
+ data["image_parallel_generation"] = self.image_parallel_generation
572
+ data["image_remove_conversation_after_result"] = self.image_remove_conversation_after_result
573
+ data["image_remove_conversation_always"] = self.image_remove_conversation_always
574
+ data["auto_remove_invalid_accounts"] = self.auto_remove_invalid_accounts
575
+ data["auto_remove_rate_limited_accounts"] = self.auto_remove_rate_limited_accounts
576
+ data["auto_relogin_after_refresh"] = self.auto_relogin_after_refresh
577
+ data["log_levels"] = self.log_levels
578
+ data["sensitive_words"] = self.sensitive_words
579
+ data["ai_review"] = self.ai_review
580
+ data["global_system_prompt"] = self.global_system_prompt
581
+ data["default_upstream_model_name"] = self.default_upstream_model_name
582
+ data["default_thinking_effort"] = self.default_thinking_effort
583
+ data["backup"] = self.get_backup_settings()
584
+ data["image_storage"] = self.get_image_storage_settings()
585
+ data["chat_completion_cache"] = self.get_chat_completion_cache_settings()
586
+ data["proxy_runtime"] = self.get_public_proxy_runtime_settings()
587
+ data["third_party_apps"] = self.get_third_party_apps_settings()
588
+ data.pop("auth-key", None)
589
+ return data
590
+
591
+ def get_proxy_settings(self) -> str:
592
+ return str(self.data.get("proxy") or "").strip()
593
+
594
+ def get_proxy_runtime_settings(self) -> dict[str, object]:
595
+ return _normalize_proxy_runtime_settings(self.data.get("proxy_runtime"))
596
+
597
+ def get_public_proxy_runtime_settings(self) -> dict[str, object]:
598
+ runtime = copy.deepcopy(self.get_proxy_runtime_settings())
599
+ clearance = runtime.get("clearance") if isinstance(runtime.get("clearance"), dict) else {}
600
+ if isinstance(clearance, dict):
601
+ cf_cookies = str(clearance.get("cf_cookies") or "").strip()
602
+ cf_clearance = str(clearance.get("cf_clearance") or "").strip()
603
+ clearance["cf_cookies"] = ""
604
+ clearance["cf_clearance"] = ""
605
+ clearance["has_cf_cookies"] = bool(cf_cookies)
606
+ clearance["has_cf_clearance"] = bool(cf_clearance)
607
+ return runtime
608
+
609
+ def get_third_party_apps_settings(self) -> dict[str, object]:
610
+ return _normalize_third_party_apps_settings(self.data.get("third_party_apps"))
611
+
612
+ def update(self, data: dict[str, object]) -> dict[str, object]:
613
+ next_data = dict(self.data)
614
+ next_data.update(dict(data or {}))
615
+ if "backup" in next_data:
616
+ next_data["backup"] = _normalize_backup_settings(next_data.get("backup"))
617
+ if "image_storage" in next_data:
618
+ next_data["image_storage"] = _normalize_image_storage_settings(next_data.get("image_storage"))
619
+ _validate_image_storage_settings(next_data["image_storage"])
620
+ if "chat_completion_cache" in next_data:
621
+ next_data["chat_completion_cache"] = _normalize_chat_completion_cache_settings(
622
+ next_data.get("chat_completion_cache")
623
+ )
624
+ if "third_party_apps" in next_data:
625
+ next_data["third_party_apps"] = _normalize_third_party_apps_settings(next_data.get("third_party_apps"))
626
+ if "proxy_runtime" in next_data:
627
+ incoming_runtime = next_data.get("proxy_runtime")
628
+ if isinstance(incoming_runtime, dict):
629
+ previous_clearance = self.get_proxy_runtime_settings().get("clearance")
630
+ if isinstance(previous_clearance, dict):
631
+ incoming_runtime = dict(incoming_runtime)
632
+ incoming_runtime["_existing_cf_cookies"] = previous_clearance.get("cf_cookies")
633
+ incoming_runtime["_existing_cf_clearance"] = previous_clearance.get("cf_clearance")
634
+ next_data["proxy_runtime"] = _normalize_proxy_runtime_settings(incoming_runtime)
635
+ next_data.pop("backup_state", None)
636
+ self.data = next_data
637
+ self._save()
638
+ return self.get()
639
+
640
+ def get_backup_settings(self) -> dict[str, object]:
641
+ return _normalize_backup_settings(self.data.get("backup"))
642
+
643
+ def get_image_storage_settings(self) -> dict[str, object]:
644
+ return _normalize_image_storage_settings(self.data.get("image_storage"))
645
+
646
+ def get_chat_completion_cache_settings(self) -> dict[str, object]:
647
+ return _normalize_chat_completion_cache_settings(self.data.get("chat_completion_cache"))
648
+
649
+ def get_storage_backend(self) -> StorageBackend:
650
+ """获取存储后端实例(单例)"""
651
+ if self._storage_backend is None:
652
+ from services.storage.factory import create_storage_backend
653
+ self._storage_backend = create_storage_backend(DATA_DIR)
654
+ return self._storage_backend
655
+
656
+
657
+ def load_backup_state() -> dict[str, object]:
658
+ return _normalize_backup_state(_read_json_object(BACKUP_STATE_FILE, name="backup_state.json"))
659
+
660
+
661
+ def save_backup_state(state: dict[str, object]) -> dict[str, object]:
662
+ normalized = _normalize_backup_state(state)
663
+ BACKUP_STATE_FILE.write_text(json.dumps(normalized, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
664
+ return normalized
665
+
666
+
667
+ config = ConfigStore(CONFIG_FILE)