dsh-plugin-image-workbench 0.0.0-stage → 1.1.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.en.md +256 -0
- package/README.md +256 -2
- package/client.js +241 -0
- package/cordis.patch.yml +14 -0
- package/host.js +641 -0
- package/install.mjs +387 -0
- package/package.json +57 -4
- package/scripts/vendor-snapshot.py +154 -0
- package/server/access.py +26 -0
- package/server/app.py +1329 -0
- package/server/catalog.json +1 -0
- package/server/catalog.py +147 -0
- package/server/composer.py +391 -0
- package/server/drafts.py +158 -0
- package/server/history.py +575 -0
- package/server/imaging.py +172 -0
- package/server/pool.py +1335 -0
- package/server/pool_admin.py +921 -0
- package/server/pool_service.py +1073 -0
- package/server/requirements.txt +6 -0
- package/server/smoke_check.py +402 -0
- package/server/static/access.html +202 -0
- package/server/static/index.html +4725 -0
- package/vendor/chatgpt2api/README.md +13 -0
- package/vendor/chatgpt2api/SOURCE.json +11 -0
- package/vendor/chatgpt2api/runtime/uv.exe +0 -0
- package/vendor/chatgpt2api/source/.env.example +82 -0
- package/vendor/chatgpt2api/source/.python-version +1 -0
- package/vendor/chatgpt2api/source/LICENSE +21 -0
- package/vendor/chatgpt2api/source/README.md +391 -0
- package/vendor/chatgpt2api/source/VERSION +1 -0
- package/vendor/chatgpt2api/source/api/__init__.py +2 -0
- package/vendor/chatgpt2api/source/api/accounts.py +537 -0
- package/vendor/chatgpt2api/source/api/ai.py +217 -0
- package/vendor/chatgpt2api/source/api/app.py +62 -0
- package/vendor/chatgpt2api/source/api/errors.py +46 -0
- package/vendor/chatgpt2api/source/api/image_inputs.py +309 -0
- package/vendor/chatgpt2api/source/api/image_tasks.py +120 -0
- package/vendor/chatgpt2api/source/api/support.py +134 -0
- package/vendor/chatgpt2api/source/api/system.py +365 -0
- package/vendor/chatgpt2api/source/assets/atlascloud.svg +1 -0
- package/vendor/chatgpt2api/source/assets/ipwo.png +0 -0
- package/vendor/chatgpt2api/source/assets/kunrouai.svg +50 -0
- package/vendor/chatgpt2api/source/config.default.json +108 -0
- package/vendor/chatgpt2api/source/main.py +9 -0
- package/vendor/chatgpt2api/source/pyproject.toml +27 -0
- package/vendor/chatgpt2api/source/scripts/init_proxy_config.py +186 -0
- package/vendor/chatgpt2api/source/scripts/migrate_storage.py +163 -0
- package/vendor/chatgpt2api/source/scripts/privoxy-warp.conf +13 -0
- package/vendor/chatgpt2api/source/scripts/test_outlook_token_mailbox.py +385 -0
- package/vendor/chatgpt2api/source/scripts/test_storage.py +129 -0
- package/vendor/chatgpt2api/source/scripts/verify_oauth_refresh.py +92 -0
- package/vendor/chatgpt2api/source/services/__init__.py +0 -0
- package/vendor/chatgpt2api/source/services/account_service.py +1757 -0
- package/vendor/chatgpt2api/source/services/auth_service.py +253 -0
- package/vendor/chatgpt2api/source/services/backup_service.py +671 -0
- package/vendor/chatgpt2api/source/services/config.py +667 -0
- package/vendor/chatgpt2api/source/services/content_filter.py +242 -0
- package/vendor/chatgpt2api/source/services/cpa_service.py +315 -0
- package/vendor/chatgpt2api/source/services/editable_file_task_service.py +260 -0
- package/vendor/chatgpt2api/source/services/image_service.py +376 -0
- package/vendor/chatgpt2api/source/services/image_storage_service.py +405 -0
- package/vendor/chatgpt2api/source/services/image_tags_service.py +76 -0
- package/vendor/chatgpt2api/source/services/image_task_service.py +550 -0
- package/vendor/chatgpt2api/source/services/log_service.py +337 -0
- package/vendor/chatgpt2api/source/services/model_service.py +182 -0
- package/vendor/chatgpt2api/source/services/oauth_login_service.py +267 -0
- package/vendor/chatgpt2api/source/services/openai_backend_api.py +2763 -0
- package/vendor/chatgpt2api/source/services/openai_oauth.py +32 -0
- package/vendor/chatgpt2api/source/services/protocol/__init__.py +2 -0
- package/vendor/chatgpt2api/source/services/protocol/anthropic_v1_messages.py +307 -0
- package/vendor/chatgpt2api/source/services/protocol/chat_completion_cache.py +238 -0
- package/vendor/chatgpt2api/source/services/protocol/conversation.py +1644 -0
- package/vendor/chatgpt2api/source/services/protocol/error_response.py +124 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_search.py +19 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_chat_complete.py +316 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_edit.py +86 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_image_generations.py +42 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_models.py +53 -0
- package/vendor/chatgpt2api/source/services/protocol/openai_v1_response.py +453 -0
- package/vendor/chatgpt2api/source/services/protocol/web_search_tool.py +160 -0
- package/vendor/chatgpt2api/source/services/proxy_service.py +648 -0
- package/vendor/chatgpt2api/source/services/storage/__init__.py +5 -0
- package/vendor/chatgpt2api/source/services/storage/base.py +38 -0
- package/vendor/chatgpt2api/source/services/storage/database_storage.py +194 -0
- package/vendor/chatgpt2api/source/services/storage/factory.py +104 -0
- package/vendor/chatgpt2api/source/services/storage/git_storage.py +183 -0
- package/vendor/chatgpt2api/source/services/storage/json_storage.py +95 -0
- package/vendor/chatgpt2api/source/services/sub2api_service.py +531 -0
- package/vendor/chatgpt2api/source/utils/__init__.py +0 -0
- package/vendor/chatgpt2api/source/utils/helper.py +469 -0
- package/vendor/chatgpt2api/source/utils/image_tokens.py +346 -0
- package/vendor/chatgpt2api/source/utils/log.py +110 -0
- package/vendor/chatgpt2api/source/utils/pkce.py +19 -0
- package/vendor/chatgpt2api/source/utils/pow.py +207 -0
- package/vendor/chatgpt2api/source/utils/sentinel.py +159 -0
- package/vendor/chatgpt2api/source/utils/turnstile.py +184 -0
- package/vendor/chatgpt2api/source/uv.lock +904 -0
- package/vendor/chatgpt2api/source/web/bun.lock +1501 -0
- package/vendor/chatgpt2api/source/web/components.json +24 -0
- package/vendor/chatgpt2api/source/web/eslint.config.mjs +17 -0
- package/vendor/chatgpt2api/source/web/next.config.ts +40 -0
- package/vendor/chatgpt2api/source/web/package.json +58 -0
- package/vendor/chatgpt2api/source/web/postcss.config.mjs +5 -0
- package/vendor/chatgpt2api/source/web/public/github.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/next.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/openai.svg +1 -0
- package/vendor/chatgpt2api/source/web/public/vercel.svg +1 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/components/account-import-dialog.tsx +925 -0
- package/vendor/chatgpt2api/source/web/src/app/accounts/page.tsx +1307 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/chat-panel.tsx +224 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/editable-file-panel.tsx +444 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/ppt-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/psd-panel.tsx +9 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/search-panel.tsx +172 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/skill-panel.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/components/types.ts +39 -0
- package/vendor/chatgpt2api/source/web/src/app/debug/page.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/app/favicon.ico +0 -0
- package/vendor/chatgpt2api/source/web/src/app/globals.css +231 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-composer.tsx +542 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-results.tsx +554 -0
- package/vendor/chatgpt2api/source/web/src/app/image/components/image-sidebar.tsx +176 -0
- package/vendor/chatgpt2api/source/web/src/app/image/page.tsx +1799 -0
- package/vendor/chatgpt2api/source/web/src/app/image-manager/page.tsx +732 -0
- package/vendor/chatgpt2api/source/web/src/app/layout.tsx +50 -0
- package/vendor/chatgpt2api/source/web/src/app/login/page.tsx +101 -0
- package/vendor/chatgpt2api/source/web/src/app/logs/page.tsx +356 -0
- package/vendor/chatgpt2api/source/web/src/app/page.tsx +30 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/api-docs-card.tsx +341 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/backup-settings-card.tsx +447 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/base-url-card.tsx +71 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/config-card.tsx +491 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pool-dialog.tsx +108 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/cpa-pools-card.tsx +169 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/import-browser-dialog.tsx +185 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-runtime-card.tsx +379 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings-card.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/proxy-settings.tsx +200 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/settings-header.tsx +12 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/sub2api-connections.tsx +886 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/third-party-apps-card.tsx +81 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/components/user-keys-card.tsx +422 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/page.tsx +135 -0
- package/vendor/chatgpt2api/source/web/src/app/settings/store.ts +1069 -0
- package/vendor/chatgpt2api/source/web/src/components/date-range-filter.tsx +49 -0
- package/vendor/chatgpt2api/source/web/src/components/header-actions.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/image-lightbox.tsx +434 -0
- package/vendor/chatgpt2api/source/web/src/components/image-thumbnail.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-script.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/theme-toggle.tsx +14 -0
- package/vendor/chatgpt2api/source/web/src/components/top-nav.tsx +263 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/animated-theme-toggler.tsx +256 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/badge.tsx +41 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/button.tsx +59 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/calendar.tsx +46 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/card.tsx +61 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/checkbox.tsx +27 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dia-text-reveal.tsx +267 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/dialog.tsx +132 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/field.tsx +13 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/input.tsx +19 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/label.tsx +24 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/morphing-text.tsx +149 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/popover.tsx +38 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/select.tsx +175 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/sheet.tsx +143 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/table.tsx +29 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/tabs.tsx +91 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/text-3d-flip.tsx +269 -0
- package/vendor/chatgpt2api/source/web/src/components/ui/textarea.tsx +18 -0
- package/vendor/chatgpt2api/source/web/src/components/version-release-dialog.tsx +127 -0
- package/vendor/chatgpt2api/source/web/src/constants/common-env.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/hooks/use-version-check.ts +85 -0
- package/vendor/chatgpt2api/source/web/src/lib/api.ts +926 -0
- package/vendor/chatgpt2api/source/web/src/lib/auth-session.ts +26 -0
- package/vendor/chatgpt2api/source/web/src/lib/release.ts +25 -0
- package/vendor/chatgpt2api/source/web/src/lib/request.ts +93 -0
- package/vendor/chatgpt2api/source/web/src/lib/use-auth-guard.ts +89 -0
- package/vendor/chatgpt2api/source/web/src/lib/utils.ts +6 -0
- package/vendor/chatgpt2api/source/web/src/store/auth.ts +112 -0
- package/vendor/chatgpt2api/source/web/src/store/editable-file-history.ts +33 -0
- package/vendor/chatgpt2api/source/web/src/store/image-conversations.ts +318 -0
- package/vendor/chatgpt2api/source/web/tsconfig.json +42 -0
|
@@ -0,0 +1,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
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
"""CLIProxyAPI integration for browsing remote auth files and importing selected tokens."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
import uuid
|
|
8
|
+
from concurrent.futures import ThreadPoolExecutor, as_completed
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from threading import Lock
|
|
12
|
+
|
|
13
|
+
from curl_cffi.requests import Session
|
|
14
|
+
|
|
15
|
+
from services.account_service import account_service
|
|
16
|
+
from services.config import DATA_DIR
|
|
17
|
+
from services.proxy_service import proxy_settings
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
CPA_CONFIG_FILE = DATA_DIR / "cpa_config.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _new_id() -> str:
|
|
24
|
+
return uuid.uuid4().hex[:12]
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _now_iso() -> str:
|
|
28
|
+
return datetime.now(timezone.utc).isoformat()
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _normalize_import_job(raw: object, *, fail_unfinished: bool) -> dict | None:
|
|
32
|
+
if not isinstance(raw, dict):
|
|
33
|
+
return None
|
|
34
|
+
status = str(raw.get("status") or "failed").strip() or "failed"
|
|
35
|
+
if fail_unfinished and status in {"pending", "running"}:
|
|
36
|
+
status = "failed"
|
|
37
|
+
return {
|
|
38
|
+
"job_id": str(raw.get("job_id") or uuid.uuid4().hex).strip(),
|
|
39
|
+
"status": status,
|
|
40
|
+
"created_at": str(raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
|
41
|
+
"updated_at": str(raw.get("updated_at") or raw.get("created_at") or _now_iso()).strip() or _now_iso(),
|
|
42
|
+
"total": int(raw.get("total") or 0),
|
|
43
|
+
"completed": int(raw.get("completed") or 0),
|
|
44
|
+
"added": int(raw.get("added") or 0),
|
|
45
|
+
"skipped": int(raw.get("skipped") or 0),
|
|
46
|
+
"refreshed": int(raw.get("refreshed") or 0),
|
|
47
|
+
"failed": int(raw.get("failed") or 0),
|
|
48
|
+
"errors": raw.get("errors") if isinstance(raw.get("errors"), list) else [],
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _normalize_pool(raw: dict) -> dict:
|
|
53
|
+
return {
|
|
54
|
+
"id": str(raw.get("id") or _new_id()).strip(),
|
|
55
|
+
"name": str(raw.get("name") or "").strip(),
|
|
56
|
+
"base_url": str(raw.get("base_url") or "").strip(),
|
|
57
|
+
"secret_key": str(raw.get("secret_key") or "").strip(),
|
|
58
|
+
"import_job": _normalize_import_job(raw.get("import_job"), fail_unfinished=True),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _management_headers(secret_key: str) -> dict[str, str]:
|
|
63
|
+
return {
|
|
64
|
+
"Authorization": f"Bearer {secret_key}",
|
|
65
|
+
"Accept": "application/json",
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
class CPAConfig:
|
|
70
|
+
def __init__(self, store_file: Path):
|
|
71
|
+
self._store_file = store_file
|
|
72
|
+
self._lock = Lock()
|
|
73
|
+
self._pools: list[dict] = self._load()
|
|
74
|
+
|
|
75
|
+
def _load(self) -> list[dict]:
|
|
76
|
+
if not self._store_file.exists():
|
|
77
|
+
return []
|
|
78
|
+
try:
|
|
79
|
+
raw = json.loads(self._store_file.read_text(encoding="utf-8"))
|
|
80
|
+
if isinstance(raw, dict) and "base_url" in raw:
|
|
81
|
+
pool = _normalize_pool(raw)
|
|
82
|
+
return [pool] if pool["base_url"] else []
|
|
83
|
+
if isinstance(raw, list):
|
|
84
|
+
return [_normalize_pool(item) for item in raw if isinstance(item, dict)]
|
|
85
|
+
except Exception:
|
|
86
|
+
pass
|
|
87
|
+
return []
|
|
88
|
+
|
|
89
|
+
def _save(self) -> None:
|
|
90
|
+
self._store_file.parent.mkdir(parents=True, exist_ok=True)
|
|
91
|
+
self._store_file.write_text(json.dumps(self._pools, ensure_ascii=False, indent=2) + "\n", encoding="utf-8")
|
|
92
|
+
|
|
93
|
+
def list_pools(self) -> list[dict]:
|
|
94
|
+
with self._lock:
|
|
95
|
+
return [dict(pool) for pool in self._pools]
|
|
96
|
+
|
|
97
|
+
def get_pool(self, pool_id: str) -> dict | None:
|
|
98
|
+
with self._lock:
|
|
99
|
+
for pool in self._pools:
|
|
100
|
+
if pool["id"] == pool_id:
|
|
101
|
+
return dict(pool)
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
def add_pool(self, name: str, base_url: str, secret_key: str) -> dict:
|
|
105
|
+
pool = _normalize_pool({"id": _new_id(), "name": name, "base_url": base_url, "secret_key": secret_key})
|
|
106
|
+
with self._lock:
|
|
107
|
+
self._pools.append(pool)
|
|
108
|
+
self._save()
|
|
109
|
+
return dict(pool)
|
|
110
|
+
|
|
111
|
+
def update_pool(self, pool_id: str, updates: dict) -> dict | None:
|
|
112
|
+
with self._lock:
|
|
113
|
+
for index, pool in enumerate(self._pools):
|
|
114
|
+
if pool["id"] != pool_id:
|
|
115
|
+
continue
|
|
116
|
+
merged = {**pool, **{key: value for key, value in updates.items() if value is not None}, "id": pool_id}
|
|
117
|
+
self._pools[index] = _normalize_pool(merged)
|
|
118
|
+
self._save()
|
|
119
|
+
return dict(self._pools[index])
|
|
120
|
+
return None
|
|
121
|
+
|
|
122
|
+
def delete_pool(self, pool_id: str) -> bool:
|
|
123
|
+
with self._lock:
|
|
124
|
+
before = len(self._pools)
|
|
125
|
+
self._pools = [pool for pool in self._pools if pool["id"] != pool_id]
|
|
126
|
+
if len(self._pools) < before:
|
|
127
|
+
self._save()
|
|
128
|
+
return True
|
|
129
|
+
return False
|
|
130
|
+
|
|
131
|
+
def set_import_job(self, pool_id: str, import_job: dict | None) -> dict | None:
|
|
132
|
+
with self._lock:
|
|
133
|
+
for index, pool in enumerate(self._pools):
|
|
134
|
+
if pool["id"] != pool_id:
|
|
135
|
+
continue
|
|
136
|
+
next_pool = dict(pool)
|
|
137
|
+
next_pool["import_job"] = _normalize_import_job(import_job, fail_unfinished=False)
|
|
138
|
+
self._pools[index] = next_pool
|
|
139
|
+
self._save()
|
|
140
|
+
return dict(next_pool)
|
|
141
|
+
return None
|
|
142
|
+
|
|
143
|
+
def get_import_job(self, pool_id: str) -> dict | None:
|
|
144
|
+
with self._lock:
|
|
145
|
+
for pool in self._pools:
|
|
146
|
+
if pool["id"] == pool_id:
|
|
147
|
+
job = pool.get("import_job")
|
|
148
|
+
return dict(job) if isinstance(job, dict) else None
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def list_remote_files(pool: dict) -> list[dict]:
|
|
153
|
+
base_url = str(pool.get("base_url") or "").strip()
|
|
154
|
+
secret_key = str(pool.get("secret_key") or "").strip()
|
|
155
|
+
if not base_url or not secret_key:
|
|
156
|
+
return []
|
|
157
|
+
|
|
158
|
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files"
|
|
159
|
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
|
160
|
+
try:
|
|
161
|
+
response = session.get(url, headers=_management_headers(secret_key), timeout=30)
|
|
162
|
+
if not response.ok:
|
|
163
|
+
raise RuntimeError(f"remote list failed: HTTP {response.status_code}")
|
|
164
|
+
payload = response.json()
|
|
165
|
+
finally:
|
|
166
|
+
session.close()
|
|
167
|
+
|
|
168
|
+
files = payload.get("files") if isinstance(payload, dict) else None
|
|
169
|
+
if not isinstance(files, list):
|
|
170
|
+
raise RuntimeError("remote list payload is invalid")
|
|
171
|
+
|
|
172
|
+
items: list[dict] = []
|
|
173
|
+
for item in files:
|
|
174
|
+
if not isinstance(item, dict):
|
|
175
|
+
continue
|
|
176
|
+
name = str(item.get("name") or "").strip()
|
|
177
|
+
email = str(item.get("email") or item.get("account") or "").strip()
|
|
178
|
+
if not name:
|
|
179
|
+
continue
|
|
180
|
+
items.append({"name": name, "email": email})
|
|
181
|
+
return items
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def fetch_remote_access_token(pool: dict, file_name: str) -> tuple[str | None, str | None]:
|
|
185
|
+
base_url = str(pool.get("base_url") or "").strip()
|
|
186
|
+
secret_key = str(pool.get("secret_key") or "").strip()
|
|
187
|
+
file_name = str(file_name or "").strip()
|
|
188
|
+
if not base_url or not secret_key or not file_name:
|
|
189
|
+
return None, "invalid request"
|
|
190
|
+
|
|
191
|
+
url = f"{base_url.rstrip('/')}/v0/management/auth-files/download"
|
|
192
|
+
session = Session(**proxy_settings.build_session_kwargs(verify=True))
|
|
193
|
+
try:
|
|
194
|
+
response = session.get(url, headers=_management_headers(secret_key), params={"name": file_name}, timeout=30)
|
|
195
|
+
if not response.ok:
|
|
196
|
+
return None, f"HTTP {response.status_code}"
|
|
197
|
+
payload = response.json()
|
|
198
|
+
except Exception as exc:
|
|
199
|
+
return None, str(exc)
|
|
200
|
+
finally:
|
|
201
|
+
session.close()
|
|
202
|
+
|
|
203
|
+
if not isinstance(payload, dict):
|
|
204
|
+
return None, "invalid payload"
|
|
205
|
+
|
|
206
|
+
access_token = str(payload.get("access_token") or "").strip()
|
|
207
|
+
if not access_token:
|
|
208
|
+
return None, "missing access_token"
|
|
209
|
+
return access_token, None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
class CPAImportService:
|
|
213
|
+
def __init__(self, cpa_config: CPAConfig):
|
|
214
|
+
self._config = cpa_config
|
|
215
|
+
|
|
216
|
+
def start_import(self, pool: dict, selected_files: list[str]) -> dict:
|
|
217
|
+
names = [str(name or "").strip() for name in selected_files if str(name or "").strip()]
|
|
218
|
+
if not names:
|
|
219
|
+
raise ValueError("selected files is required")
|
|
220
|
+
|
|
221
|
+
pool_id = str(pool.get("id") or "").strip()
|
|
222
|
+
job = {
|
|
223
|
+
"job_id": uuid.uuid4().hex,
|
|
224
|
+
"status": "pending",
|
|
225
|
+
"created_at": _now_iso(),
|
|
226
|
+
"updated_at": _now_iso(),
|
|
227
|
+
"total": len(names),
|
|
228
|
+
"completed": 0,
|
|
229
|
+
"added": 0,
|
|
230
|
+
"skipped": 0,
|
|
231
|
+
"refreshed": 0,
|
|
232
|
+
"failed": 0,
|
|
233
|
+
"errors": [],
|
|
234
|
+
}
|
|
235
|
+
saved_pool = self._config.set_import_job(pool_id, job)
|
|
236
|
+
if saved_pool is None:
|
|
237
|
+
raise ValueError("pool not found")
|
|
238
|
+
|
|
239
|
+
thread = threading.Thread(
|
|
240
|
+
target=self._run_import,
|
|
241
|
+
args=(pool_id, pool, names),
|
|
242
|
+
name=f"cpa-import-{pool_id}",
|
|
243
|
+
daemon=True,
|
|
244
|
+
)
|
|
245
|
+
thread.start()
|
|
246
|
+
return dict(saved_pool.get("import_job") or job)
|
|
247
|
+
|
|
248
|
+
def _update_job(self, pool_id: str, **updates) -> dict | None:
|
|
249
|
+
current = self._config.get_import_job(pool_id)
|
|
250
|
+
if current is None:
|
|
251
|
+
return None
|
|
252
|
+
next_job = {**current, **updates, "updated_at": _now_iso()}
|
|
253
|
+
pool = self._config.set_import_job(pool_id, next_job)
|
|
254
|
+
if pool is None:
|
|
255
|
+
return None
|
|
256
|
+
job = pool.get("import_job")
|
|
257
|
+
return dict(job) if isinstance(job, dict) else None
|
|
258
|
+
|
|
259
|
+
def _append_error(self, pool_id: str, file_name: str, message: str) -> None:
|
|
260
|
+
current = self._config.get_import_job(pool_id)
|
|
261
|
+
if current is None:
|
|
262
|
+
return
|
|
263
|
+
errors = list(current.get("errors") or [])
|
|
264
|
+
errors.append({"name": file_name, "error": message})
|
|
265
|
+
self._update_job(pool_id, errors=errors, failed=len(errors))
|
|
266
|
+
|
|
267
|
+
def _run_import(self, pool_id: str, pool: dict, names: list[str]) -> None:
|
|
268
|
+
self._update_job(pool_id, status="running")
|
|
269
|
+
|
|
270
|
+
tokens: list[str] = []
|
|
271
|
+
max_workers = min(16, max(1, len(names)))
|
|
272
|
+
with ThreadPoolExecutor(max_workers=max_workers) as executor:
|
|
273
|
+
future_map = {executor.submit(fetch_remote_access_token, pool, name): name for name in names}
|
|
274
|
+
for future in as_completed(future_map):
|
|
275
|
+
file_name = future_map[future]
|
|
276
|
+
try:
|
|
277
|
+
token, error = future.result()
|
|
278
|
+
except Exception as exc:
|
|
279
|
+
token, error = None, str(exc)
|
|
280
|
+
|
|
281
|
+
if token:
|
|
282
|
+
tokens.append(token)
|
|
283
|
+
else:
|
|
284
|
+
self._append_error(pool_id, file_name, error or "unknown error")
|
|
285
|
+
|
|
286
|
+
current = self._config.get_import_job(pool_id) or {}
|
|
287
|
+
failed = len(current.get("errors") or [])
|
|
288
|
+
self._update_job(pool_id, completed=int(current.get("completed") or 0) + 1, failed=failed)
|
|
289
|
+
|
|
290
|
+
if not tokens:
|
|
291
|
+
current = self._config.get_import_job(pool_id) or {}
|
|
292
|
+
self._update_job(
|
|
293
|
+
pool_id,
|
|
294
|
+
status="failed",
|
|
295
|
+
completed=int(current.get("total") or 0),
|
|
296
|
+
failed=len(current.get("errors") or []),
|
|
297
|
+
)
|
|
298
|
+
return
|
|
299
|
+
|
|
300
|
+
add_result = account_service.add_accounts(tokens, source_type="codex")
|
|
301
|
+
refresh_result = account_service.refresh_accounts(tokens)
|
|
302
|
+
current = self._config.get_import_job(pool_id) or {}
|
|
303
|
+
self._update_job(
|
|
304
|
+
pool_id,
|
|
305
|
+
status="completed",
|
|
306
|
+
completed=len(names),
|
|
307
|
+
added=int(add_result.get("added") or 0),
|
|
308
|
+
skipped=int(add_result.get("skipped") or 0),
|
|
309
|
+
refreshed=int(refresh_result.get("refreshed") or 0),
|
|
310
|
+
failed=len(current.get("errors") or []),
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
cpa_config = CPAConfig(CPA_CONFIG_FILE)
|
|
315
|
+
cpa_import_service = CPAImportService(cpa_config)
|