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,469 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import json
|
|
4
|
+
import mimetypes
|
|
5
|
+
import re
|
|
6
|
+
import time
|
|
7
|
+
import uuid
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from typing import Any, Iterator
|
|
10
|
+
from urllib.parse import urlparse
|
|
11
|
+
|
|
12
|
+
from curl_cffi import requests
|
|
13
|
+
from fastapi import HTTPException
|
|
14
|
+
from services.proxy_service import proxy_settings
|
|
15
|
+
from utils.log import logger
|
|
16
|
+
|
|
17
|
+
BASE_IMAGE_MODELS = {"gpt-image-2", "codex-gpt-image-2"}
|
|
18
|
+
IMAGE_MODEL_PLAN_TYPES = ("plus", "team", "pro")
|
|
19
|
+
CODEX_IMAGE_MODEL = "codex-gpt-image-2"
|
|
20
|
+
PREFIXED_CODEX_IMAGE_MODELS = {
|
|
21
|
+
f"{plan_type}-{CODEX_IMAGE_MODEL}"
|
|
22
|
+
for plan_type in IMAGE_MODEL_PLAN_TYPES
|
|
23
|
+
}
|
|
24
|
+
IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
|
|
25
|
+
PUBLIC_IMAGE_MODELS = BASE_IMAGE_MODELS | PREFIXED_CODEX_IMAGE_MODELS
|
|
26
|
+
OUTPUT_DIR = Path(__file__).resolve().parent / "output"
|
|
27
|
+
|
|
28
|
+
SUPPORTED_JSON_IMAGE_MIME_TYPES = {"image/png", "image/jpeg", "image/jpg", "image/webp", "image/gif"}
|
|
29
|
+
MAX_JSON_IMAGE_BYTES = 10 * 1024 * 1024
|
|
30
|
+
MAX_JSON_EDIT_IMAGES = 10
|
|
31
|
+
DATA_URL_IMAGE_RE = re.compile(r"^data:(?P<mime>[-+./\w]+);base64,(?P<data>.*)$", re.DOTALL)
|
|
32
|
+
REMOTE_IMAGE_TIMEOUT_SECONDS = 20
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _image_extension(mime_type: str) -> str:
|
|
36
|
+
image_type = mime_type.split("/", 1)[1].split(";", 1)[0].lower() if "/" in mime_type else "png"
|
|
37
|
+
return "jpg" if image_type == "jpeg" else image_type or "png"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _decode_json_image_string(value: str, index: int, filename: str | None = None, mime_type: str | None = None) -> tuple[bytes, str, str]:
|
|
41
|
+
text = value.strip()
|
|
42
|
+
if not text:
|
|
43
|
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
|
44
|
+
match = DATA_URL_IMAGE_RE.match(text)
|
|
45
|
+
if match:
|
|
46
|
+
resolved_mime = (match.group("mime") or "image/png").lower()
|
|
47
|
+
encoded = match.group("data")
|
|
48
|
+
else:
|
|
49
|
+
if text.startswith(("http://", "https://")):
|
|
50
|
+
raise HTTPException(status_code=400, detail={"error": "remote image URLs are not supported"})
|
|
51
|
+
resolved_mime = (mime_type or "image/png").lower()
|
|
52
|
+
encoded = text
|
|
53
|
+
if resolved_mime == "image/jpg":
|
|
54
|
+
resolved_mime = "image/jpeg"
|
|
55
|
+
if resolved_mime not in SUPPORTED_JSON_IMAGE_MIME_TYPES:
|
|
56
|
+
raise HTTPException(status_code=400, detail={"error": "unsupported image mime type"})
|
|
57
|
+
try:
|
|
58
|
+
image_data = base64.b64decode(encoded, validate=True)
|
|
59
|
+
except Exception as exc:
|
|
60
|
+
raise HTTPException(status_code=400, detail={"error": "invalid base64 image data"}) from exc
|
|
61
|
+
if not image_data:
|
|
62
|
+
raise HTTPException(status_code=400, detail={"error": "image file is empty"})
|
|
63
|
+
if len(image_data) > MAX_JSON_IMAGE_BYTES:
|
|
64
|
+
raise HTTPException(status_code=400, detail={"error": "image file is too large"})
|
|
65
|
+
return image_data, filename or f"image_{index}.{_image_extension(resolved_mime)}", resolved_mime
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _extract_json_image_value(item: object) -> tuple[str, str | None, str | None]:
|
|
69
|
+
if isinstance(item, str):
|
|
70
|
+
return item, None, None
|
|
71
|
+
if not isinstance(item, dict):
|
|
72
|
+
raise HTTPException(status_code=400, detail={"error": "image entry must be a base64 string or object"})
|
|
73
|
+
filename = str(item.get("filename") or item.get("file_name") or "").strip() or None
|
|
74
|
+
mime_type = str(item.get("mime_type") or item.get("mimeType") or "").strip() or None
|
|
75
|
+
value = item.get("b64_json") or item.get("base64")
|
|
76
|
+
if not value:
|
|
77
|
+
image_url = item.get("image_url") or item.get("url")
|
|
78
|
+
if isinstance(image_url, dict):
|
|
79
|
+
filename = filename or str(image_url.get("filename") or image_url.get("file_name") or "").strip() or None
|
|
80
|
+
mime_type = mime_type or str(image_url.get("mime_type") or image_url.get("mimeType") or "").strip() or None
|
|
81
|
+
value = image_url.get("url") or image_url.get("image_url")
|
|
82
|
+
else:
|
|
83
|
+
value = image_url
|
|
84
|
+
if not isinstance(value, str) or not value.strip():
|
|
85
|
+
raise HTTPException(status_code=400, detail={"error": "image entry must include image data"})
|
|
86
|
+
return value, filename, mime_type
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def normalize_json_edit_images(image: object = None, images: object = None) -> list[tuple[bytes, str, str]]:
|
|
90
|
+
raw_images = images if images is not None else image
|
|
91
|
+
if raw_images is None:
|
|
92
|
+
raise HTTPException(status_code=400, detail={"error": "image file is required"})
|
|
93
|
+
entries = raw_images if isinstance(raw_images, list) else [raw_images]
|
|
94
|
+
if not entries:
|
|
95
|
+
raise HTTPException(status_code=400, detail={"error": "image file is required"})
|
|
96
|
+
if len(entries) > MAX_JSON_EDIT_IMAGES:
|
|
97
|
+
raise HTTPException(status_code=400, detail={"error": f"images supports up to {MAX_JSON_EDIT_IMAGES} items"})
|
|
98
|
+
normalized = []
|
|
99
|
+
for index, item in enumerate(entries, start=1):
|
|
100
|
+
value, filename, mime_type = _extract_json_image_value(item)
|
|
101
|
+
normalized.append(_decode_json_image_string(value, index, filename, mime_type))
|
|
102
|
+
return normalized
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def new_uuid() -> str:
|
|
106
|
+
return str(uuid.uuid4())
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def split_image_model(model: object) -> tuple[str | None, str | None]:
|
|
110
|
+
normalized = str(model or "").strip().lower()
|
|
111
|
+
if not normalized:
|
|
112
|
+
return None, None
|
|
113
|
+
if normalized in BASE_IMAGE_MODELS:
|
|
114
|
+
return None, normalized
|
|
115
|
+
for plan_type in IMAGE_MODEL_PLAN_TYPES:
|
|
116
|
+
prefix = f"{plan_type}-"
|
|
117
|
+
if normalized.startswith(prefix):
|
|
118
|
+
base_model = normalized[len(prefix):]
|
|
119
|
+
if base_model == CODEX_IMAGE_MODEL:
|
|
120
|
+
return plan_type, base_model
|
|
121
|
+
return None, None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def is_supported_image_model(model: object) -> bool:
|
|
125
|
+
_, base_model = split_image_model(model)
|
|
126
|
+
return base_model is not None
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def is_codex_image_model(model: object) -> bool:
|
|
130
|
+
_, base_model = split_image_model(model)
|
|
131
|
+
return base_model == CODEX_IMAGE_MODEL
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def is_image_chat_request(body: dict[str, object]) -> bool:
|
|
135
|
+
model = str(body.get("model") or "").strip()
|
|
136
|
+
modalities = body.get("modalities")
|
|
137
|
+
if is_supported_image_model(model):
|
|
138
|
+
return True
|
|
139
|
+
return isinstance(modalities, list) and "image" in {str(item or "").strip().lower() for item in modalities}
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
_UPSTREAM_BODY_LOG_LIMIT = 500
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
class UpstreamHTTPError(RuntimeError):
|
|
146
|
+
"""Raised when an upstream HTTP call returns a non-2xx status.
|
|
147
|
+
|
|
148
|
+
Carries structured fields (status_code, body, retry_after) so callers can
|
|
149
|
+
branch on status code instead of string-matching on str(exc). The full
|
|
150
|
+
body is preserved on the instance; the formatted message truncates it
|
|
151
|
+
to keep log lines reasonable.
|
|
152
|
+
"""
|
|
153
|
+
|
|
154
|
+
def __init__(
|
|
155
|
+
self,
|
|
156
|
+
context: str,
|
|
157
|
+
status_code: int,
|
|
158
|
+
body: Any,
|
|
159
|
+
retry_after: int | None = None,
|
|
160
|
+
) -> None:
|
|
161
|
+
self.context = context
|
|
162
|
+
self.status_code = status_code
|
|
163
|
+
self.body = body
|
|
164
|
+
self.retry_after = retry_after
|
|
165
|
+
if isinstance(body, (dict, list)):
|
|
166
|
+
try:
|
|
167
|
+
body_str = json.dumps(body, ensure_ascii=False)
|
|
168
|
+
except (TypeError, ValueError):
|
|
169
|
+
body_str = repr(body)
|
|
170
|
+
else:
|
|
171
|
+
body_str = str(body)
|
|
172
|
+
if len(body_str) > _UPSTREAM_BODY_LOG_LIMIT:
|
|
173
|
+
body_str = body_str[:_UPSTREAM_BODY_LOG_LIMIT] + "…[truncated]"
|
|
174
|
+
super().__init__(f"{context} failed: status={status_code}, body={body_str}")
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def ensure_ok(response: requests.Response, context: str) -> None:
|
|
178
|
+
if 200 <= response.status_code < 300:
|
|
179
|
+
return
|
|
180
|
+
body: Any = response.text
|
|
181
|
+
try:
|
|
182
|
+
body = response.json()
|
|
183
|
+
except Exception:
|
|
184
|
+
pass
|
|
185
|
+
retry_after_header = response.headers.get("Retry-After") if hasattr(response, "headers") else None
|
|
186
|
+
retry_after: int | None = None
|
|
187
|
+
if retry_after_header is not None:
|
|
188
|
+
ra_str = str(retry_after_header).strip()
|
|
189
|
+
if ra_str.isdigit():
|
|
190
|
+
retry_after = int(ra_str)
|
|
191
|
+
raise UpstreamHTTPError(context, response.status_code, body, retry_after=retry_after)
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def sse_json_stream(items) -> Iterator[str]:
|
|
195
|
+
yield ": stream-open\n\n"
|
|
196
|
+
try:
|
|
197
|
+
for item in items:
|
|
198
|
+
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
|
|
199
|
+
except Exception as exc:
|
|
200
|
+
logger.warning({
|
|
201
|
+
"event": "sse_stream_error",
|
|
202
|
+
"error_type": exc.__class__.__name__,
|
|
203
|
+
"error": str(exc),
|
|
204
|
+
})
|
|
205
|
+
error = exc.to_openai_error() if hasattr(exc, "to_openai_error") else {
|
|
206
|
+
"error": {"message": str(exc), "type": exc.__class__.__name__}
|
|
207
|
+
}
|
|
208
|
+
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
|
|
209
|
+
yield "data: [DONE]\n\n"
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def anthropic_sse_stream(items) -> Iterator[str]:
|
|
213
|
+
try:
|
|
214
|
+
for item in items:
|
|
215
|
+
event = str(item.get("type") or "message_delta") if isinstance(item, dict) else "message_delta"
|
|
216
|
+
yield f"event: {event}\n"
|
|
217
|
+
yield f"data: {json.dumps(item, ensure_ascii=False)}\n\n"
|
|
218
|
+
except Exception as exc:
|
|
219
|
+
logger.warning({
|
|
220
|
+
"event": "anthropic_sse_stream_error",
|
|
221
|
+
"error_type": exc.__class__.__name__,
|
|
222
|
+
"error": str(exc),
|
|
223
|
+
})
|
|
224
|
+
error = {"type": "error", "error": {"type": exc.__class__.__name__, "message": str(exc)}}
|
|
225
|
+
yield "event: error\n"
|
|
226
|
+
yield f"data: {json.dumps(error, ensure_ascii=False)}\n\n"
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def iter_sse_payloads(response: requests.Response) -> Iterator[str]:
|
|
230
|
+
for raw_line in response.iter_lines():
|
|
231
|
+
if not raw_line:
|
|
232
|
+
continue
|
|
233
|
+
line = raw_line.decode("utf-8", errors="ignore") if isinstance(raw_line, bytes) else str(raw_line)
|
|
234
|
+
if not line.startswith("data:"):
|
|
235
|
+
continue
|
|
236
|
+
payload = line[5:].strip()
|
|
237
|
+
if payload:
|
|
238
|
+
yield payload
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def save_images_from_text(text: str, prefix: str) -> list[Path]:
|
|
242
|
+
OUTPUT_DIR.mkdir(parents=True, exist_ok=True)
|
|
243
|
+
matches = re.findall(r"data:image/[^;]+;base64,[A-Za-z0-9+/=]+", text or "")
|
|
244
|
+
saved_paths: list[Path] = []
|
|
245
|
+
timestamp = int(time.time() * 1000)
|
|
246
|
+
for index, data_url in enumerate(matches, start=1):
|
|
247
|
+
header, encoded = data_url.split(",", 1)
|
|
248
|
+
image_type = header.split(";")[0].removeprefix("data:image/").strip() or "png"
|
|
249
|
+
extension = "jpg" if image_type == "jpeg" else image_type
|
|
250
|
+
output_path = OUTPUT_DIR / f"{prefix}_{timestamp}_{index}.{extension}"
|
|
251
|
+
output_path.write_bytes(base64.b64decode(encoded))
|
|
252
|
+
saved_paths.append(output_path)
|
|
253
|
+
return saved_paths
|
|
254
|
+
|
|
255
|
+
|
|
256
|
+
def anonymize_token(token: object) -> str:
|
|
257
|
+
value = str(token or "").strip()
|
|
258
|
+
if not value:
|
|
259
|
+
return "token:empty"
|
|
260
|
+
digest = hashlib.sha256(value.encode("utf-8")).hexdigest()[:10]
|
|
261
|
+
return f"token:{digest}"
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def extract_response_prompt(input_value: object) -> str:
|
|
265
|
+
if isinstance(input_value, str):
|
|
266
|
+
return input_value.strip()
|
|
267
|
+
if isinstance(input_value, dict):
|
|
268
|
+
role = str(input_value.get("role") or "").strip().lower()
|
|
269
|
+
if role and role != "user":
|
|
270
|
+
return ""
|
|
271
|
+
return extract_prompt_from_message_content(input_value.get("content"))
|
|
272
|
+
if not isinstance(input_value, list):
|
|
273
|
+
return ""
|
|
274
|
+
prompt_parts: list[str] = []
|
|
275
|
+
for item in input_value:
|
|
276
|
+
if isinstance(item, dict) and str(item.get("type") or "").strip() == "input_text":
|
|
277
|
+
text = str(item.get("text") or "").strip()
|
|
278
|
+
if text:
|
|
279
|
+
prompt_parts.append(text)
|
|
280
|
+
continue
|
|
281
|
+
if not isinstance(item, dict):
|
|
282
|
+
continue
|
|
283
|
+
role = str(item.get("role") or "").strip().lower()
|
|
284
|
+
if role and role != "user":
|
|
285
|
+
continue
|
|
286
|
+
prompt = extract_prompt_from_message_content(item.get("content"))
|
|
287
|
+
if prompt:
|
|
288
|
+
prompt_parts.append(prompt)
|
|
289
|
+
return "\n".join(prompt_parts).strip()
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def has_response_image_generation_tool(body: dict[str, object]) -> bool:
|
|
293
|
+
tools = body.get("tools")
|
|
294
|
+
if isinstance(tools, list):
|
|
295
|
+
for tool in tools:
|
|
296
|
+
if isinstance(tool, dict) and str(tool.get("type") or "").strip() == "image_generation":
|
|
297
|
+
return True
|
|
298
|
+
tool_choice = body.get("tool_choice")
|
|
299
|
+
return isinstance(tool_choice, dict) and str(tool_choice.get("type") or "").strip() == "image_generation"
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def extract_prompt_from_message_content(content: object) -> str:
|
|
303
|
+
if isinstance(content, str):
|
|
304
|
+
return content.strip()
|
|
305
|
+
if not isinstance(content, list):
|
|
306
|
+
return ""
|
|
307
|
+
parts: list[str] = []
|
|
308
|
+
for item in content:
|
|
309
|
+
if not isinstance(item, dict):
|
|
310
|
+
continue
|
|
311
|
+
item_type = str(item.get("type") or "").strip()
|
|
312
|
+
if item_type == "text":
|
|
313
|
+
text = str(item.get("text") or "").strip()
|
|
314
|
+
if text:
|
|
315
|
+
parts.append(text)
|
|
316
|
+
elif item_type == "input_text":
|
|
317
|
+
text = str(item.get("text") or item.get("input_text") or "").strip()
|
|
318
|
+
if text:
|
|
319
|
+
parts.append(text)
|
|
320
|
+
return "\n".join(parts).strip()
|
|
321
|
+
|
|
322
|
+
|
|
323
|
+
def _message_image_url(value: object) -> str:
|
|
324
|
+
if isinstance(value, dict):
|
|
325
|
+
return str(value.get("url") or value.get("image_url") or "").strip()
|
|
326
|
+
return str(value or "").strip()
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _decode_message_image_url(value: object) -> tuple[bytes, str] | None:
|
|
330
|
+
source = _message_image_url(value)
|
|
331
|
+
if source.startswith("data:"):
|
|
332
|
+
header, _, data = source.partition(",")
|
|
333
|
+
mime = header.split(";")[0].removeprefix("data:") or "image/png"
|
|
334
|
+
return base64.b64decode(data), mime
|
|
335
|
+
if not source.startswith(("http://", "https://")):
|
|
336
|
+
return None
|
|
337
|
+
parsed = urlparse(source)
|
|
338
|
+
if parsed.scheme not in {"http", "https"} or not parsed.netloc:
|
|
339
|
+
return None
|
|
340
|
+
|
|
341
|
+
try:
|
|
342
|
+
response = requests.get(
|
|
343
|
+
source,
|
|
344
|
+
headers={"Accept": "image/*,*/*;q=0.8", "User-Agent": "chatgpt2api vision fetcher"},
|
|
345
|
+
timeout=REMOTE_IMAGE_TIMEOUT_SECONDS,
|
|
346
|
+
allow_redirects=True,
|
|
347
|
+
**proxy_settings.build_session_kwargs(),
|
|
348
|
+
)
|
|
349
|
+
except Exception as exc:
|
|
350
|
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: {exc}"}) from exc
|
|
351
|
+
if not 200 <= response.status_code < 300:
|
|
352
|
+
raise HTTPException(status_code=400, detail={"error": f"image_url fetch failed: HTTP {response.status_code}"})
|
|
353
|
+
content_length = str(response.headers.get("content-length") or "").strip()
|
|
354
|
+
if content_length.isdigit() and int(content_length) > MAX_JSON_IMAGE_BYTES:
|
|
355
|
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
|
|
356
|
+
image_data = response.content
|
|
357
|
+
if not image_data:
|
|
358
|
+
raise HTTPException(status_code=400, detail={"error": "image_url returned empty content"})
|
|
359
|
+
if len(image_data) > MAX_JSON_IMAGE_BYTES:
|
|
360
|
+
raise HTTPException(status_code=400, detail={"error": "image_url exceeds 10MB limit"})
|
|
361
|
+
mime = str(response.headers.get("content-type") or "image/png").split(";", 1)[0].lower()
|
|
362
|
+
guessed_mime = mimetypes.guess_type(parsed.path)[0] or ""
|
|
363
|
+
if mime and not mime.startswith("image/") and mime not in {"application/octet-stream", "binary/octet-stream"}:
|
|
364
|
+
raise HTTPException(status_code=400, detail={"error": "image_url must point to an image"})
|
|
365
|
+
if not mime.startswith("image/") and guessed_mime.startswith("image/"):
|
|
366
|
+
mime = guessed_mime
|
|
367
|
+
if not mime.startswith("image/"):
|
|
368
|
+
mime = "image/png"
|
|
369
|
+
return image_data, mime
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _decode_message_image_object(item: dict[str, object]) -> tuple[bytes, str] | None:
|
|
373
|
+
data = item.get("data")
|
|
374
|
+
if isinstance(data, (bytes, bytearray)):
|
|
375
|
+
return bytes(data), str(item.get("mime") or item.get("mime_type") or "image/png")
|
|
376
|
+
for key in ("image_url", "url"):
|
|
377
|
+
image = _decode_message_image_url(item.get(key))
|
|
378
|
+
if image:
|
|
379
|
+
return image
|
|
380
|
+
value = item.get("b64_json") or item.get("base64")
|
|
381
|
+
if isinstance(value, str) and value.strip():
|
|
382
|
+
image_data, _, mime = _decode_json_image_string(
|
|
383
|
+
value,
|
|
384
|
+
1,
|
|
385
|
+
mime_type=str(item.get("mime") or item.get("mime_type") or item.get("mimeType") or "image/png"),
|
|
386
|
+
)
|
|
387
|
+
return image_data, mime
|
|
388
|
+
source = item.get("source")
|
|
389
|
+
if isinstance(source, dict) and str(source.get("type") or "") == "base64":
|
|
390
|
+
encoded = str(source.get("data") or "")
|
|
391
|
+
mime = str(source.get("media_type") or source.get("mime_type") or "image/png")
|
|
392
|
+
image_data, _, resolved_mime = _decode_json_image_string(encoded, 1, mime_type=mime)
|
|
393
|
+
return image_data, resolved_mime
|
|
394
|
+
return None
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def extract_image_from_message_content(content: object) -> list[tuple[bytes, str]]:
|
|
398
|
+
if not isinstance(content, list):
|
|
399
|
+
return []
|
|
400
|
+
images = []
|
|
401
|
+
for item in content:
|
|
402
|
+
if not isinstance(item, dict):
|
|
403
|
+
continue
|
|
404
|
+
item_type = str(item.get("type") or "").strip()
|
|
405
|
+
if item_type == "image_url":
|
|
406
|
+
image = _decode_message_image_url(item.get("image_url") or item.get("url") or item)
|
|
407
|
+
if image:
|
|
408
|
+
images.append(image)
|
|
409
|
+
elif item_type in {"input_image", "image"}:
|
|
410
|
+
image = _decode_message_image_object(item)
|
|
411
|
+
if image:
|
|
412
|
+
images.append(image)
|
|
413
|
+
return images
|
|
414
|
+
|
|
415
|
+
|
|
416
|
+
def extract_chat_image(body: dict[str, object]) -> list[tuple[bytes, str]]:
|
|
417
|
+
messages = body.get("messages")
|
|
418
|
+
if not isinstance(messages, list):
|
|
419
|
+
return []
|
|
420
|
+
for message in reversed(messages):
|
|
421
|
+
if not isinstance(message, dict):
|
|
422
|
+
continue
|
|
423
|
+
if str(message.get("role") or "").strip().lower() != "user":
|
|
424
|
+
continue
|
|
425
|
+
images = extract_image_from_message_content(message.get("content"))
|
|
426
|
+
if images:
|
|
427
|
+
return images
|
|
428
|
+
return []
|
|
429
|
+
|
|
430
|
+
|
|
431
|
+
def extract_chat_prompt(body: dict[str, object]) -> str:
|
|
432
|
+
direct_prompt = str(body.get("prompt") or "").strip()
|
|
433
|
+
if direct_prompt:
|
|
434
|
+
return direct_prompt
|
|
435
|
+
messages = body.get("messages")
|
|
436
|
+
if not isinstance(messages, list):
|
|
437
|
+
return ""
|
|
438
|
+
prompt_parts: list[str] = []
|
|
439
|
+
for message in messages:
|
|
440
|
+
if not isinstance(message, dict):
|
|
441
|
+
continue
|
|
442
|
+
if str(message.get("role") or "").strip().lower() != "user":
|
|
443
|
+
continue
|
|
444
|
+
prompt = extract_prompt_from_message_content(message.get("content"))
|
|
445
|
+
if prompt:
|
|
446
|
+
prompt_parts.append(prompt)
|
|
447
|
+
return "\n".join(prompt_parts).strip()
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def parse_image_count(raw_value: object) -> int:
|
|
451
|
+
try:
|
|
452
|
+
value = int(raw_value or 1)
|
|
453
|
+
except (TypeError, ValueError) as exc:
|
|
454
|
+
raise HTTPException(status_code=400, detail={"error": "n must be an integer"}) from exc
|
|
455
|
+
if value < 1 or value > 4:
|
|
456
|
+
raise HTTPException(status_code=400, detail={"error": "n must be between 1 and 4"})
|
|
457
|
+
return value
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def build_chat_image_markdown_content(image_result: dict[str, object]) -> str:
|
|
461
|
+
image_items = image_result.get("data") if isinstance(image_result.get("data"), list) else []
|
|
462
|
+
markdown_images: list[str] = []
|
|
463
|
+
for index, item in enumerate(image_items, start=1):
|
|
464
|
+
if not isinstance(item, dict):
|
|
465
|
+
continue
|
|
466
|
+
b64_json = str(item.get("b64_json") or "").strip()
|
|
467
|
+
if b64_json:
|
|
468
|
+
markdown_images.append(f"")
|
|
469
|
+
return "\n\n".join(markdown_images) if markdown_images else "Image generation completed."
|