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.
- package/LICENSE +21 -21
- package/README.en.md +5 -3
- package/README.md +7 -3
- package/client.js +241 -241
- package/cordis.patch.yml +14 -14
- package/host.js +51 -0
- package/install.mjs +333 -387
- package/package.json +3 -2
- package/scripts/fetch-style-images.mjs +104 -0
- package/scripts/vendor-snapshot.py +154 -154
- package/server/access.py +26 -26
- package/server/app.py +1371 -1329
- package/server/catalog.py +147 -147
- package/server/composer.py +391 -391
- package/server/drafts.py +158 -158
- package/server/history.py +575 -575
- package/server/imaging.py +172 -172
- package/server/pool.py +1335 -1335
- package/server/pool_admin.py +921 -921
- package/server/pool_service.py +1073 -1073
- package/server/requirements.txt +6 -6
- package/server/smoke_check.py +402 -402
- package/server/static/access.html +202 -202
- package/server/static/index.html +15 -0
- package/vendor/chatgpt2api/source/.env.example +82 -82
- package/vendor/chatgpt2api/source/.python-version +1 -1
- package/vendor/chatgpt2api/source/LICENSE +21 -21
- package/vendor/chatgpt2api/source/README.md +391 -391
- package/vendor/chatgpt2api/source/VERSION +1 -1
- package/vendor/chatgpt2api/source/api/__init__.py +2 -2
- package/vendor/chatgpt2api/source/api/accounts.py +537 -537
- package/vendor/chatgpt2api/source/api/ai.py +217 -217
- package/vendor/chatgpt2api/source/api/app.py +62 -62
- package/vendor/chatgpt2api/source/api/errors.py +46 -46
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -309
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -120
- package/vendor/chatgpt2api/source/api/support.py +134 -134
- package/vendor/chatgpt2api/source/api/system.py +365 -365
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -50
- package/vendor/chatgpt2api/source/config.default.json +108 -108
- package/vendor/chatgpt2api/source/main.py +9 -9
- package/vendor/chatgpt2api/source/pyproject.toml +27 -27
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -186
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -163
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -13
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -385
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -129
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -92
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -1757
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -253
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -671
- package/vendor/chatgpt2api/source/services/config.py +667 -667
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -242
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -315
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -260
- package/vendor/chatgpt2api/source/services/image_service.py +376 -376
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -405
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -76
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -550
- package/vendor/chatgpt2api/source/services/log_service.py +337 -337
- package/vendor/chatgpt2api/source/services/model_service.py +182 -182
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -267
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -2763
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -32
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -2
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -307
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -238
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -1644
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -124
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -19
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -316
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -86
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -42
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -53
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -453
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -160
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -648
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -5
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -38
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -194
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -104
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -183
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -95
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -531
- package/vendor/chatgpt2api/source/utils/helper.py +469 -469
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -346
- package/vendor/chatgpt2api/source/utils/log.py +110 -110
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -19
- package/vendor/chatgpt2api/source/utils/pow.py +207 -207
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -159
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -184
- package/vendor/chatgpt2api/source/uv.lock +904 -904
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -1501
- package/vendor/chatgpt2api/source/web/components.json +24 -24
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -17
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -40
- package/vendor/chatgpt2api/source/web/package.json +58 -58
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -5
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -925
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -1307
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -224
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -444
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -9
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -172
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -39
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/app/globals.css +230 -230
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -542
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -554
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -176
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -1799
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -732
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -50
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -101
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -356
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -30
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -341
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -447
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -71
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -491
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -108
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -169
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -185
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -379
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -200
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -12
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -886
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -81
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -422
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -135
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -1069
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -49
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -434
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -14
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -263
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -256
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -41
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -59
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -46
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -61
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -27
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -267
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -132
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -13
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -19
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -24
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -149
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -38
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -175
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -143
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -29
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -91
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -269
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -18
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -127
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -85
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -926
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -26
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -25
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -93
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -89
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -6
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -112
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -33
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -318
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -42
|
@@ -1,242 +1,242 @@
|
|
|
1
|
-
from __future__ import annotations
|
|
2
|
-
|
|
3
|
-
import re
|
|
4
|
-
|
|
5
|
-
from curl_cffi import requests
|
|
6
|
-
from fastapi import HTTPException
|
|
7
|
-
|
|
8
|
-
from services.config import config
|
|
9
|
-
from services.proxy_service import proxy_settings
|
|
10
|
-
from utils.log import logger
|
|
11
|
-
|
|
12
|
-
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
|
|
13
|
-
|
|
14
|
-
# Strip base64 image data URIs before review: a text-only review model can't
|
|
15
|
-
# analyze image bytes, and a single inlined image easily blows past the token
|
|
16
|
-
# budget of the upstream review service.
|
|
17
|
-
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
|
|
18
|
-
|
|
19
|
-
# Cap aligned to the upstream review service's max context. If text still
|
|
20
|
-
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
|
|
21
|
-
# the system prompt and the most recent user message survive.
|
|
22
|
-
_MAX_REVIEW_TEXT_LEN = 100_000
|
|
23
|
-
_TRUNCATION_MARKER = "\n…[truncated]…\n"
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
def _text(value: object) -> str:
|
|
27
|
-
if isinstance(value, str):
|
|
28
|
-
return value
|
|
29
|
-
if isinstance(value, list):
|
|
30
|
-
return "\n".join(_text(item) for item in value)
|
|
31
|
-
if isinstance(value, dict):
|
|
32
|
-
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
|
|
33
|
-
return ""
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def request_text(*values: object) -> str:
|
|
37
|
-
return "\n".join(part for value in values if (part := _text(value).strip()))
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
def request_shape(*values: object) -> dict[str, int]:
|
|
41
|
-
"""Return a safe structural summary without logging prompts or image bytes."""
|
|
42
|
-
stats = {
|
|
43
|
-
"response_message_items": 0,
|
|
44
|
-
"input_image_parts": 0,
|
|
45
|
-
"image_url_parts": 0,
|
|
46
|
-
"image_parts": 0,
|
|
47
|
-
"data_url_images": 0,
|
|
48
|
-
"remote_image_urls": 0,
|
|
49
|
-
"literal_image_placeholders": 0,
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
def walk(value: object, key: str = "") -> None:
|
|
53
|
-
if isinstance(value, str):
|
|
54
|
-
text = value.strip()
|
|
55
|
-
lower = text.lower()
|
|
56
|
-
if "<image>" in lower:
|
|
57
|
-
stats["literal_image_placeholders"] += 1
|
|
58
|
-
if lower.startswith("data:image/"):
|
|
59
|
-
stats["data_url_images"] += 1
|
|
60
|
-
elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
|
|
61
|
-
stats["remote_image_urls"] += 1
|
|
62
|
-
return
|
|
63
|
-
if isinstance(value, list):
|
|
64
|
-
for item in value:
|
|
65
|
-
walk(item, key)
|
|
66
|
-
return
|
|
67
|
-
if not isinstance(value, dict):
|
|
68
|
-
return
|
|
69
|
-
item_type = str(value.get("type") or "").strip()
|
|
70
|
-
if item_type == "message":
|
|
71
|
-
stats["response_message_items"] += 1
|
|
72
|
-
elif item_type == "input_image":
|
|
73
|
-
stats["input_image_parts"] += 1
|
|
74
|
-
elif item_type == "image_url":
|
|
75
|
-
stats["image_url_parts"] += 1
|
|
76
|
-
elif item_type == "image":
|
|
77
|
-
stats["image_parts"] += 1
|
|
78
|
-
for child_key, child in value.items():
|
|
79
|
-
walk(child, str(child_key))
|
|
80
|
-
|
|
81
|
-
for value in values:
|
|
82
|
-
walk(value)
|
|
83
|
-
return {key: value for key, value in stats.items() if value}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
|
|
87
|
-
"""Strip base64 data URIs and truncate to the review-service context limit.
|
|
88
|
-
|
|
89
|
-
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
|
|
90
|
-
and truncated_chars so callers can emit structured logs.
|
|
91
|
-
"""
|
|
92
|
-
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
|
|
93
|
-
truncated_chars = 0
|
|
94
|
-
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
|
|
95
|
-
# Reserve marker space so the result stays within the cap.
|
|
96
|
-
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
|
|
97
|
-
truncated_chars = len(sanitized) - 2 * half
|
|
98
|
-
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
|
|
99
|
-
stats = {
|
|
100
|
-
"base64_blocks_stripped": base64_blocks_stripped,
|
|
101
|
-
"truncated_chars": truncated_chars,
|
|
102
|
-
}
|
|
103
|
-
return sanitized, stats
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def _extract_review_decision(data: object) -> str | None:
|
|
107
|
-
"""Defensively pull the decision text out of the review service response.
|
|
108
|
-
|
|
109
|
-
Returns None when the response shape doesn't match the OpenAI chat-completion
|
|
110
|
-
contract (e.g. {"error": ...} with no choices). The caller treats None as
|
|
111
|
-
"undecided" and applies the configured fail-open policy.
|
|
112
|
-
"""
|
|
113
|
-
if not isinstance(data, dict):
|
|
114
|
-
return None
|
|
115
|
-
choices = data.get("choices")
|
|
116
|
-
if not isinstance(choices, list) or not choices:
|
|
117
|
-
return None
|
|
118
|
-
first = choices[0]
|
|
119
|
-
if not isinstance(first, dict):
|
|
120
|
-
return None
|
|
121
|
-
message = first.get("message")
|
|
122
|
-
if not isinstance(message, dict):
|
|
123
|
-
return None
|
|
124
|
-
content = message.get("content")
|
|
125
|
-
if content is None:
|
|
126
|
-
return None
|
|
127
|
-
return str(content).strip().lower()
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
def _is_allow_decision(decision: str) -> bool:
|
|
131
|
-
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def _is_reject_decision(decision: str) -> bool:
|
|
135
|
-
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
def _resolve_fail_open(review: dict) -> bool:
|
|
139
|
-
"""Resolve fail_open from review config. Defaults to True."""
|
|
140
|
-
value = review.get("fail_open")
|
|
141
|
-
if value is None:
|
|
142
|
-
return True
|
|
143
|
-
if isinstance(value, bool):
|
|
144
|
-
return value
|
|
145
|
-
if isinstance(value, str):
|
|
146
|
-
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
147
|
-
return bool(value)
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
def check_request(text: str) -> None:
|
|
151
|
-
text = str(text or "")
|
|
152
|
-
if not text.strip():
|
|
153
|
-
return
|
|
154
|
-
# Local sensitive-word match runs on the raw text (cheap, no network).
|
|
155
|
-
for word in config.sensitive_words:
|
|
156
|
-
if word in text:
|
|
157
|
-
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
|
|
158
|
-
review = config.ai_review
|
|
159
|
-
if not review.get("enabled"):
|
|
160
|
-
return
|
|
161
|
-
base_url = str(review.get("base_url") or "").strip().rstrip("/")
|
|
162
|
-
api_key = str(review.get("api_key") or "").strip()
|
|
163
|
-
model = str(review.get("model") or "").strip()
|
|
164
|
-
if not base_url or not api_key or not model:
|
|
165
|
-
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
|
|
166
|
-
|
|
167
|
-
fail_open = _resolve_fail_open(review)
|
|
168
|
-
|
|
169
|
-
review_text, sanitize_stats = _sanitize_for_review(text)
|
|
170
|
-
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
|
|
171
|
-
logger.info({
|
|
172
|
-
"event": "ai_review_text_sanitized",
|
|
173
|
-
"original_text_len": len(text),
|
|
174
|
-
"review_text_len": len(review_text),
|
|
175
|
-
**sanitize_stats,
|
|
176
|
-
})
|
|
177
|
-
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
|
|
178
|
-
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
|
|
179
|
-
|
|
180
|
-
# fail_open=True (default): on upstream failure or ambiguous reply, let the
|
|
181
|
-
# request through. The review is a soft safety net; one missed review is
|
|
182
|
-
# preferable to a 5xx storm when the review service is flaky. Set
|
|
183
|
-
# config.ai_review.fail_open=false for strict-compliance deployments.
|
|
184
|
-
def _on_failure(event_payload: dict) -> None:
|
|
185
|
-
logger.warning(event_payload)
|
|
186
|
-
if not fail_open:
|
|
187
|
-
raise HTTPException(
|
|
188
|
-
status_code=503,
|
|
189
|
-
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
|
|
190
|
-
)
|
|
191
|
-
|
|
192
|
-
try:
|
|
193
|
-
response = requests.post(
|
|
194
|
-
f"{base_url}/v1/chat/completions",
|
|
195
|
-
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
196
|
-
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
|
|
197
|
-
timeout=60,
|
|
198
|
-
**proxy_settings.build_session_kwargs(),
|
|
199
|
-
)
|
|
200
|
-
except Exception as exc:
|
|
201
|
-
_on_failure({
|
|
202
|
-
"event": "ai_review_request_failed",
|
|
203
|
-
"error": str(exc),
|
|
204
|
-
"error_type": exc.__class__.__name__,
|
|
205
|
-
"review_text_len": len(review_text),
|
|
206
|
-
"original_text_len": len(text),
|
|
207
|
-
})
|
|
208
|
-
return
|
|
209
|
-
|
|
210
|
-
try:
|
|
211
|
-
data = response.json()
|
|
212
|
-
except Exception as exc:
|
|
213
|
-
_on_failure({
|
|
214
|
-
"event": "ai_review_response_not_json",
|
|
215
|
-
"status_code": response.status_code,
|
|
216
|
-
"body_preview": str(response.text or "")[:200],
|
|
217
|
-
"error": str(exc),
|
|
218
|
-
})
|
|
219
|
-
return
|
|
220
|
-
|
|
221
|
-
decision = _extract_review_decision(data)
|
|
222
|
-
if decision is None:
|
|
223
|
-
_on_failure({
|
|
224
|
-
"event": "ai_review_malformed_response",
|
|
225
|
-
"status_code": response.status_code,
|
|
226
|
-
"body_preview": str(data)[:300],
|
|
227
|
-
"review_text_len": len(review_text),
|
|
228
|
-
"original_text_len": len(text),
|
|
229
|
-
})
|
|
230
|
-
return
|
|
231
|
-
|
|
232
|
-
if _is_allow_decision(decision):
|
|
233
|
-
return
|
|
234
|
-
if _is_reject_decision(decision):
|
|
235
|
-
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
|
|
236
|
-
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
|
|
237
|
-
_on_failure({
|
|
238
|
-
"event": "ai_review_ambiguous_decision",
|
|
239
|
-
"decision": decision[:100],
|
|
240
|
-
"review_text_len": len(review_text),
|
|
241
|
-
})
|
|
242
|
-
return
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
from curl_cffi import requests
|
|
6
|
+
from fastapi import HTTPException
|
|
7
|
+
|
|
8
|
+
from services.config import config
|
|
9
|
+
from services.proxy_service import proxy_settings
|
|
10
|
+
from utils.log import logger
|
|
11
|
+
|
|
12
|
+
DEFAULT_REVIEW_PROMPT = "判断用户请求是否允许。只回答 ALLOW 或 REJECT。"
|
|
13
|
+
|
|
14
|
+
# Strip base64 image data URIs before review: a text-only review model can't
|
|
15
|
+
# analyze image bytes, and a single inlined image easily blows past the token
|
|
16
|
+
# budget of the upstream review service.
|
|
17
|
+
_BASE64_DATA_URI = re.compile(r"data:[\w/.+;-]+;base64,[A-Za-z0-9+/=]+")
|
|
18
|
+
|
|
19
|
+
# Cap aligned to the upstream review service's max context. If text still
|
|
20
|
+
# exceeds the cap after base64 stripping, keep equal head/tail halves so both
|
|
21
|
+
# the system prompt and the most recent user message survive.
|
|
22
|
+
_MAX_REVIEW_TEXT_LEN = 100_000
|
|
23
|
+
_TRUNCATION_MARKER = "\n…[truncated]…\n"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _text(value: object) -> str:
|
|
27
|
+
if isinstance(value, str):
|
|
28
|
+
return value
|
|
29
|
+
if isinstance(value, list):
|
|
30
|
+
return "\n".join(_text(item) for item in value)
|
|
31
|
+
if isinstance(value, dict):
|
|
32
|
+
return "\n".join(_text(value.get(key)) for key in ("text", "input_text", "content", "input", "instructions", "system", "prompt"))
|
|
33
|
+
return ""
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def request_text(*values: object) -> str:
|
|
37
|
+
return "\n".join(part for value in values if (part := _text(value).strip()))
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def request_shape(*values: object) -> dict[str, int]:
|
|
41
|
+
"""Return a safe structural summary without logging prompts or image bytes."""
|
|
42
|
+
stats = {
|
|
43
|
+
"response_message_items": 0,
|
|
44
|
+
"input_image_parts": 0,
|
|
45
|
+
"image_url_parts": 0,
|
|
46
|
+
"image_parts": 0,
|
|
47
|
+
"data_url_images": 0,
|
|
48
|
+
"remote_image_urls": 0,
|
|
49
|
+
"literal_image_placeholders": 0,
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
def walk(value: object, key: str = "") -> None:
|
|
53
|
+
if isinstance(value, str):
|
|
54
|
+
text = value.strip()
|
|
55
|
+
lower = text.lower()
|
|
56
|
+
if "<image>" in lower:
|
|
57
|
+
stats["literal_image_placeholders"] += 1
|
|
58
|
+
if lower.startswith("data:image/"):
|
|
59
|
+
stats["data_url_images"] += 1
|
|
60
|
+
elif key in {"image_url", "url"} and lower.startswith(("http://", "https://")):
|
|
61
|
+
stats["remote_image_urls"] += 1
|
|
62
|
+
return
|
|
63
|
+
if isinstance(value, list):
|
|
64
|
+
for item in value:
|
|
65
|
+
walk(item, key)
|
|
66
|
+
return
|
|
67
|
+
if not isinstance(value, dict):
|
|
68
|
+
return
|
|
69
|
+
item_type = str(value.get("type") or "").strip()
|
|
70
|
+
if item_type == "message":
|
|
71
|
+
stats["response_message_items"] += 1
|
|
72
|
+
elif item_type == "input_image":
|
|
73
|
+
stats["input_image_parts"] += 1
|
|
74
|
+
elif item_type == "image_url":
|
|
75
|
+
stats["image_url_parts"] += 1
|
|
76
|
+
elif item_type == "image":
|
|
77
|
+
stats["image_parts"] += 1
|
|
78
|
+
for child_key, child in value.items():
|
|
79
|
+
walk(child, str(child_key))
|
|
80
|
+
|
|
81
|
+
for value in values:
|
|
82
|
+
walk(value)
|
|
83
|
+
return {key: value for key, value in stats.items() if value}
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _sanitize_for_review(text: str) -> tuple[str, dict[str, int]]:
|
|
87
|
+
"""Strip base64 data URIs and truncate to the review-service context limit.
|
|
88
|
+
|
|
89
|
+
Returns (sanitized_text, stats) where stats carries base64_blocks_stripped
|
|
90
|
+
and truncated_chars so callers can emit structured logs.
|
|
91
|
+
"""
|
|
92
|
+
sanitized, base64_blocks_stripped = _BASE64_DATA_URI.subn("[image]", text)
|
|
93
|
+
truncated_chars = 0
|
|
94
|
+
if len(sanitized) > _MAX_REVIEW_TEXT_LEN:
|
|
95
|
+
# Reserve marker space so the result stays within the cap.
|
|
96
|
+
half = (_MAX_REVIEW_TEXT_LEN - len(_TRUNCATION_MARKER)) // 2
|
|
97
|
+
truncated_chars = len(sanitized) - 2 * half
|
|
98
|
+
sanitized = sanitized[:half] + _TRUNCATION_MARKER + sanitized[-half:]
|
|
99
|
+
stats = {
|
|
100
|
+
"base64_blocks_stripped": base64_blocks_stripped,
|
|
101
|
+
"truncated_chars": truncated_chars,
|
|
102
|
+
}
|
|
103
|
+
return sanitized, stats
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _extract_review_decision(data: object) -> str | None:
|
|
107
|
+
"""Defensively pull the decision text out of the review service response.
|
|
108
|
+
|
|
109
|
+
Returns None when the response shape doesn't match the OpenAI chat-completion
|
|
110
|
+
contract (e.g. {"error": ...} with no choices). The caller treats None as
|
|
111
|
+
"undecided" and applies the configured fail-open policy.
|
|
112
|
+
"""
|
|
113
|
+
if not isinstance(data, dict):
|
|
114
|
+
return None
|
|
115
|
+
choices = data.get("choices")
|
|
116
|
+
if not isinstance(choices, list) or not choices:
|
|
117
|
+
return None
|
|
118
|
+
first = choices[0]
|
|
119
|
+
if not isinstance(first, dict):
|
|
120
|
+
return None
|
|
121
|
+
message = first.get("message")
|
|
122
|
+
if not isinstance(message, dict):
|
|
123
|
+
return None
|
|
124
|
+
content = message.get("content")
|
|
125
|
+
if content is None:
|
|
126
|
+
return None
|
|
127
|
+
return str(content).strip().lower()
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _is_allow_decision(decision: str) -> bool:
|
|
131
|
+
return decision.startswith(("allow", "pass", "true", "yes", "通过", "允许", "安全"))
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _is_reject_decision(decision: str) -> bool:
|
|
135
|
+
return decision.startswith(("reject", "deny", "block", "false", "no", "拒绝", "不允许", "违规", "禁止"))
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _resolve_fail_open(review: dict) -> bool:
|
|
139
|
+
"""Resolve fail_open from review config. Defaults to True."""
|
|
140
|
+
value = review.get("fail_open")
|
|
141
|
+
if value is None:
|
|
142
|
+
return True
|
|
143
|
+
if isinstance(value, bool):
|
|
144
|
+
return value
|
|
145
|
+
if isinstance(value, str):
|
|
146
|
+
return value.strip().lower() in {"1", "true", "yes", "on"}
|
|
147
|
+
return bool(value)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def check_request(text: str) -> None:
|
|
151
|
+
text = str(text or "")
|
|
152
|
+
if not text.strip():
|
|
153
|
+
return
|
|
154
|
+
# Local sensitive-word match runs on the raw text (cheap, no network).
|
|
155
|
+
for word in config.sensitive_words:
|
|
156
|
+
if word in text:
|
|
157
|
+
raise HTTPException(status_code=400, detail={"error": "检测到敏感词,拒绝本次任务"})
|
|
158
|
+
review = config.ai_review
|
|
159
|
+
if not review.get("enabled"):
|
|
160
|
+
return
|
|
161
|
+
base_url = str(review.get("base_url") or "").strip().rstrip("/")
|
|
162
|
+
api_key = str(review.get("api_key") or "").strip()
|
|
163
|
+
model = str(review.get("model") or "").strip()
|
|
164
|
+
if not base_url or not api_key or not model:
|
|
165
|
+
raise HTTPException(status_code=400, detail={"error": "ai review config is incomplete"})
|
|
166
|
+
|
|
167
|
+
fail_open = _resolve_fail_open(review)
|
|
168
|
+
|
|
169
|
+
review_text, sanitize_stats = _sanitize_for_review(text)
|
|
170
|
+
if sanitize_stats["base64_blocks_stripped"] or sanitize_stats["truncated_chars"]:
|
|
171
|
+
logger.info({
|
|
172
|
+
"event": "ai_review_text_sanitized",
|
|
173
|
+
"original_text_len": len(text),
|
|
174
|
+
"review_text_len": len(review_text),
|
|
175
|
+
**sanitize_stats,
|
|
176
|
+
})
|
|
177
|
+
prompt = str(review.get("prompt") or DEFAULT_REVIEW_PROMPT).strip()
|
|
178
|
+
content = f"{prompt}\n\n用户请求:\n{review_text}\n\n只回答 ALLOW 或 REJECT。"
|
|
179
|
+
|
|
180
|
+
# fail_open=True (default): on upstream failure or ambiguous reply, let the
|
|
181
|
+
# request through. The review is a soft safety net; one missed review is
|
|
182
|
+
# preferable to a 5xx storm when the review service is flaky. Set
|
|
183
|
+
# config.ai_review.fail_open=false for strict-compliance deployments.
|
|
184
|
+
def _on_failure(event_payload: dict) -> None:
|
|
185
|
+
logger.warning(event_payload)
|
|
186
|
+
if not fail_open:
|
|
187
|
+
raise HTTPException(
|
|
188
|
+
status_code=503,
|
|
189
|
+
detail={"error": "AI 审核服务暂时不可用,请稍后重试"},
|
|
190
|
+
)
|
|
191
|
+
|
|
192
|
+
try:
|
|
193
|
+
response = requests.post(
|
|
194
|
+
f"{base_url}/v1/chat/completions",
|
|
195
|
+
headers={"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"},
|
|
196
|
+
json={"model": model, "messages": [{"role": "user", "content": content}], "temperature": 0},
|
|
197
|
+
timeout=60,
|
|
198
|
+
**proxy_settings.build_session_kwargs(),
|
|
199
|
+
)
|
|
200
|
+
except Exception as exc:
|
|
201
|
+
_on_failure({
|
|
202
|
+
"event": "ai_review_request_failed",
|
|
203
|
+
"error": str(exc),
|
|
204
|
+
"error_type": exc.__class__.__name__,
|
|
205
|
+
"review_text_len": len(review_text),
|
|
206
|
+
"original_text_len": len(text),
|
|
207
|
+
})
|
|
208
|
+
return
|
|
209
|
+
|
|
210
|
+
try:
|
|
211
|
+
data = response.json()
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
_on_failure({
|
|
214
|
+
"event": "ai_review_response_not_json",
|
|
215
|
+
"status_code": response.status_code,
|
|
216
|
+
"body_preview": str(response.text or "")[:200],
|
|
217
|
+
"error": str(exc),
|
|
218
|
+
})
|
|
219
|
+
return
|
|
220
|
+
|
|
221
|
+
decision = _extract_review_decision(data)
|
|
222
|
+
if decision is None:
|
|
223
|
+
_on_failure({
|
|
224
|
+
"event": "ai_review_malformed_response",
|
|
225
|
+
"status_code": response.status_code,
|
|
226
|
+
"body_preview": str(data)[:300],
|
|
227
|
+
"review_text_len": len(review_text),
|
|
228
|
+
"original_text_len": len(text),
|
|
229
|
+
})
|
|
230
|
+
return
|
|
231
|
+
|
|
232
|
+
if _is_allow_decision(decision):
|
|
233
|
+
return
|
|
234
|
+
if _is_reject_decision(decision):
|
|
235
|
+
raise HTTPException(status_code=400, detail={"error": "AI 审核未通过,拒绝本次任务"})
|
|
236
|
+
# Ambiguous decisions (e.g. "MAYBE", empty content) fall back to fail-open policy.
|
|
237
|
+
_on_failure({
|
|
238
|
+
"event": "ai_review_ambiguous_decision",
|
|
239
|
+
"decision": decision[:100],
|
|
240
|
+
"review_text_len": len(review_text),
|
|
241
|
+
})
|
|
242
|
+
return
|