Download services/image_api_adapter.py from zlcy/study: direct link, hf CLI and curl.
- Browser
- Download file 19.2 kB
-
https://huggingface.co/spaces/zlcy/study/resolve/main/services/image_api_adapter.py
- Command line
-
hf download hf://spaces/zlcy/study/services/image_api_adapter.py
-
curl -L -o image_api_adapter.py https://huggingface.co/spaces/zlcy/study/resolve/main/services/image_api_adapter.py
19.2 kB
| from __future__ import annotations | |
| import html | |
| import json | |
| import re | |
| from html.parser import HTMLParser | |
| from typing import Any | |
| from urllib.parse import urlparse | |
| ADAPTER_MODEL = "gpt-5-5" | |
| MAX_ADAPTER_DOCUMENT_CHARS = 600_000 | |
| ALLOWED_PLACEHOLDERS = { | |
| "prompt", | |
| "model", | |
| "size", | |
| "n", | |
| "quality", | |
| "response_format", | |
| "api_key", | |
| "image", | |
| "image_base64", | |
| "image_data_url", | |
| "images", | |
| "images_base64", | |
| "images_data_urls", | |
| "task_id", | |
| } | |
| ALLOWED_CONTENT_TYPES = {"auto", "json", "multipart"} | |
| ALLOWED_AUTH_TYPES = {"bearer", "x-api-key", "body", "none"} | |
| PLACEHOLDER_RE = re.compile(r"\{\{\s*([a-zA-Z0-9_]+)\s*\}\}") | |
| SECRET_NAME_RE = re.compile(r"(?:\bkey\b|api[_\s-]?key|access[_\s-]?token|secret|authorization)", re.I) | |
| RESPONSE_PATH_RE = re.compile(r"^[A-Za-z0-9_\-]+(?:\.[A-Za-z0-9_\-]+)*$") | |
| NO_AUTH_RE = re.compile( | |
| r"(?:无需|不需要|不使用|免|无|不)\s*(?:API\s*)?(?:鉴权|认证|密钥|key)|" | |
| r"(?:no|without)\s+(?:api\s+key|auth(?:entication|orization)?)\s+(?:is\s+)?required|" | |
| r"public\s+(?:api|endpoint)", | |
| re.I, | |
| ) | |
| AUTH_EVIDENCE_RE = re.compile( | |
| r"Authorization\s*:|Bearer\s+|x-api-key|api[_\s-]?key|access[_\s-]?token|" | |
| r"(?:请求头|header).{0,30}(?:鉴权|认证|密钥|key)", | |
| re.I, | |
| ) | |
| class _ReadableHTMLParser(HTMLParser): | |
| def __init__(self) -> None: | |
| super().__init__(convert_charrefs=True) | |
| self.parts: list[str] = [] | |
| self.hidden_depth = 0 | |
| def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: | |
| if tag.lower() in {"script", "style", "svg", "noscript"}: | |
| self.hidden_depth += 1 | |
| elif tag.lower() in {"p", "div", "br", "li", "tr", "h1", "h2", "h3", "h4", "pre", "code"}: | |
| self.parts.append("\n") | |
| def handle_endtag(self, tag: str) -> None: | |
| if tag.lower() in {"script", "style", "svg", "noscript"} and self.hidden_depth: | |
| self.hidden_depth -= 1 | |
| elif tag.lower() in {"p", "div", "li", "tr", "h1", "h2", "h3", "h4", "pre", "code"}: | |
| self.parts.append("\n") | |
| def handle_data(self, data: str) -> None: | |
| if not self.hidden_depth and data.strip(): | |
| self.parts.append(data) | |
| def document_to_text(document: object, filename: object = "") -> str: | |
| raw = str(document or "") | |
| if not raw.strip(): | |
| raise ValueError("导入的 API 文档不能为空") | |
| if len(raw) > MAX_ADAPTER_DOCUMENT_CHARS: | |
| raise ValueError("API 文档过大,请控制在 600 KB 以内") | |
| lowered_name = str(filename or "").lower() | |
| looks_like_html = lowered_name.endswith((".html", ".htm")) or bool(re.search(r"<\s*(?:html|body|main|article|div)\b", raw, re.I)) | |
| if looks_like_html: | |
| parser = _ReadableHTMLParser() | |
| parser.feed(raw) | |
| text = " ".join(parser.parts) | |
| else: | |
| text = raw | |
| text = html.unescape(text).replace("\x00", " ") | |
| text = re.sub(r"[ \t]+", " ", text) | |
| text = re.sub(r"\n\s*\n\s*\n+", "\n\n", text) | |
| return text.strip() | |
| def redact_document_secrets(value: object) -> str: | |
| text = str(value or "") | |
| text = re.sub(r"(?i)\bBearer\s+[A-Za-z0-9._~+/=-]{8,}", "Bearer [REDACTED]", text) | |
| text = re.sub(r"\bsk-[A-Za-z0-9_-]{8,}\b", "[REDACTED_API_KEY]", text) | |
| text = re.sub( | |
| r"(?i)((?:api[_\s-]?key|access[_\s-]?token|secret|authorization|\"key\"|'key')\s*[:=]\s*[\"']?)([^\s\"'&,}<>]{8,})", | |
| r"\1[REDACTED]", | |
| text, | |
| ) | |
| return text | |
| def infer_document_auth_type(document_text: object) -> str: | |
| """Return a conservative authentication hint derived from API examples.""" | |
| text = str(document_text or "") | |
| if NO_AUTH_RE.search(text): | |
| return "none" | |
| if AUTH_EVIDENCE_RE.search(text): | |
| return "required" | |
| # Complete request examples without any auth header/key are strong evidence | |
| # for a public endpoint. This is intentionally only a hint for the adapter. | |
| if re.search(r"(?:curl\s+(?:-[^\n]+\s+)*https?://|fetch\s*\(|POST\s*/|GET\s*/)", text, re.I): | |
| return "none" | |
| return "unknown" | |
| def adapter_system_prompt() -> str: | |
| placeholders = ", ".join(f"{{{{{name}}}}}" for name in sorted(ALLOWED_PLACEHOLDERS)) | |
| return ( | |
| "You analyze third-party image generation/edit API documentation and return one safe declarative JSON adapter. " | |
| "Return JSON only, without Markdown. Never copy credentials, example keys, tokens, cookies, or secrets from the document. " | |
| "Do not return code, JavaScript, Python, shell, expressions, callbacks, or executable logic. " | |
| "Schema: {\"summary\": string, \"models\": [string], \"template\": {" | |
| "\"version\": 2, \"method\": \"POST\", " | |
| "\"auth_type\": \"bearer\"|\"x-api-key\"|\"body\"|\"none\", \"auth_name\": string, " | |
| "\"generate\": operation, \"edit\": operation|null, \"models\": models_operation|null}}. Each operation is {" | |
| "\"endpoint\": string, \"content_type\": \"auto\"|\"json\"|\"multipart\", " | |
| "\"body\": object, \"image_field\": string, " | |
| "\"poll\": null|{\"endpoint\": string, \"id_path\": string, " | |
| "\"interval_seconds\": integer, \"max_attempts\": integer}, " | |
| "\"response\": {\"image_paths\": [string], \"error_paths\": [string], " | |
| "\"success_path\": string, \"success_values\": [string|number|boolean], " | |
| "\"failure_values\": [string|number|boolean]}}. " | |
| "models_operation is {\"endpoint\": string, \"response_path\": string, \"id_path\": string}. " | |
| "When the documentation exposes a model-list endpoint, describe its GET endpoint and the paths used to reach the model collection " | |
| "and each model id; otherwise set models to null. response_path may point to an object whose values contain model arrays. " | |
| "Use dot-separated response paths. image_paths must point to an image URL/base64 value, object, or list. " | |
| "Always describe text-to-image in generate. When the documentation supports image editing, also describe image-to-image in edit, " | |
| "including its own endpoint, request format, body, image field, and response paths; otherwise set edit to null. " | |
| "Prefer the documented synchronous generation mode so the configured image_paths are available in the same response; " | |
| "include every required sync flag such as task_type=sync when the documentation shows one. " | |
| "When an operation is asynchronous, configure poll: id_path points to the task id in the initial response, " | |
| "endpoint is the documented GET status URL and must contain {{task_id}}, and response describes the final polled payload. " | |
| "Set poll to null for synchronous operations. " | |
| "For JSON image editing, use {{image_data_url}} for a documented single image string or " | |
| "{{images_data_urls}} for a documented image array. " | |
| f"The only allowed dynamic placeholders are: {placeholders}. " | |
| "For custom adapters, {{size}} is the user's original size or aspect ratio such as 16:9; do not convert it to pixel dimensions. " | |
| "Use {{api_key}} only through auth settings or a body field documented for authentication. " | |
| "If examples work without an Authorization header, API key header, cookie, or key body field, use auth_type=none. " | |
| "Never invent authentication merely because many APIs normally require it. " | |
| "If the documentation omits a property, use an empty string/list/object instead of inventing it." | |
| ) | |
| def adapter_user_prompt( | |
| document_text: str, | |
| *, | |
| filename: object = "", | |
| base_url: object = "", | |
| auth_hint: object = "", | |
| ) -> str: | |
| return ( | |
| f"Filename: {str(filename or '').strip() or 'imported-document'}\n" | |
| f"Configured API base URL: {str(base_url or '').strip() or '(not provided)'}\n\n" | |
| f"Authentication evidence: {str(auth_hint or '').strip() or 'unknown'}\n" | |
| "Treat 'none' as authoritative: emit auth_type=none and do not add an api_key placeholder.\n\n" | |
| "Analyze this redacted API documentation and create the adapter JSON:\n" | |
| f"<api_document>\n{document_text}\n</api_document>" | |
| ) | |
| def parse_model_json(value: object) -> dict[str, Any]: | |
| text = str(value or "").strip() | |
| if text.startswith("```"): | |
| text = re.sub(r"^```(?:json)?\s*", "", text, flags=re.I) | |
| text = re.sub(r"\s*```$", "", text) | |
| try: | |
| parsed = json.loads(text) | |
| except json.JSONDecodeError: | |
| start = text.find("{") | |
| end = text.rfind("}") | |
| if start < 0 or end <= start: | |
| raise ValueError("AI 未返回有效的 JSON 调用模板") | |
| try: | |
| parsed = json.loads(text[start:end + 1]) | |
| except json.JSONDecodeError as exc: | |
| raise ValueError("AI 返回的调用模板不是有效 JSON") from exc | |
| if not isinstance(parsed, dict): | |
| raise ValueError("AI 返回的调用模板必须是 JSON 对象") | |
| return parsed | |
| def _validate_template_value(value: object, *, field_name: str = "body") -> Any: | |
| if value is None or isinstance(value, (bool, int, float)): | |
| return value | |
| if isinstance(value, list): | |
| if len(value) > 50: | |
| raise ValueError("调用模板中的数组过长") | |
| return [_validate_template_value(item, field_name=field_name) for item in value] | |
| if isinstance(value, dict): | |
| if len(value) > 80: | |
| raise ValueError("调用模板中的字段过多") | |
| result: dict[str, Any] = {} | |
| for key, item in value.items(): | |
| clean_key = str(key or "").strip() | |
| if not clean_key or len(clean_key) > 100: | |
| raise ValueError("调用模板包含无效字段名") | |
| normalized_item = _validate_template_value(item, field_name=clean_key) | |
| if SECRET_NAME_RE.search(clean_key) and not ( | |
| isinstance(normalized_item, str) and normalized_item in {"{{api_key}}", ""} | |
| ): | |
| raise ValueError(f"调用模板字段 {clean_key} 不允许写入静态密钥") | |
| result[clean_key] = normalized_item | |
| return result | |
| text = str(value) | |
| placeholders = PLACEHOLDER_RE.findall(text) | |
| unknown = [item for item in placeholders if item not in ALLOWED_PLACEHOLDERS] | |
| if unknown: | |
| raise ValueError(f"调用模板包含不支持的占位符:{unknown[0]}") | |
| remainder = PLACEHOLDER_RE.sub("", text) | |
| if "{{" in remainder or "}}" in remainder: | |
| raise ValueError("调用模板包含无效占位符") | |
| if len(text) > 4000: | |
| raise ValueError("调用模板字段内容过长") | |
| return text | |
| def _normalize_adapter_endpoint(value: object, *, base_url: object = "") -> str: | |
| endpoint = str(value or "").strip() | |
| if not endpoint or len(endpoint) > 2000: | |
| raise ValueError("调用模板缺少有效 endpoint") | |
| parsed_endpoint = urlparse(endpoint) | |
| if parsed_endpoint.scheme or parsed_endpoint.netloc: | |
| if parsed_endpoint.scheme not in {"http", "https"} or not parsed_endpoint.netloc: | |
| raise ValueError("模板 endpoint 必须是 http/https 地址") | |
| configured = urlparse(str(base_url or "").strip()) | |
| if configured.netloc and parsed_endpoint.netloc.lower() != configured.netloc.lower(): | |
| raise ValueError("模板 endpoint 必须与 API URL 使用相同域名") | |
| elif not endpoint.startswith("/"): | |
| endpoint = f"/{endpoint}" | |
| return endpoint | |
| def _normalize_response_config(value: object) -> dict[str, Any]: | |
| raw_response = value if isinstance(value, dict) else {} | |
| image_paths = [str(item).strip() for item in raw_response.get("image_paths", []) if str(item).strip()] if isinstance(raw_response.get("image_paths"), list) else [] | |
| if not image_paths: | |
| raise ValueError("调用模板至少需要一个图片返回路径") | |
| error_paths = [str(item).strip() for item in raw_response.get("error_paths", []) if str(item).strip()] if isinstance(raw_response.get("error_paths"), list) else [] | |
| if any(not RESPONSE_PATH_RE.fullmatch(path) for path in image_paths + error_paths): | |
| raise ValueError("调用模板包含无效返回路径") | |
| success_path = str(raw_response.get("success_path") or "").strip() | |
| if success_path and not RESPONSE_PATH_RE.fullmatch(success_path): | |
| raise ValueError("调用模板 success_path 无效") | |
| success_values = raw_response.get("success_values") if isinstance(raw_response.get("success_values"), list) else [] | |
| failure_values = raw_response.get("failure_values") if isinstance(raw_response.get("failure_values"), list) else [] | |
| return { | |
| "image_paths": image_paths[:20], | |
| "error_paths": error_paths[:20], | |
| "success_path": success_path, | |
| "success_values": [item for item in success_values if item is None or isinstance(item, (str, int, float, bool))][:20], | |
| "failure_values": [item for item in failure_values if item is None or isinstance(item, (str, int, float, bool))][:20], | |
| } | |
| def _normalize_poll_config(value: object, *, base_url: object = "") -> dict[str, Any] | None: | |
| if value is None: | |
| return None | |
| if not isinstance(value, dict): | |
| raise ValueError("调用模板 poll 必须是对象或 null") | |
| endpoint = _normalize_adapter_endpoint(value.get("endpoint"), base_url=base_url) | |
| if "{{task_id}}" not in endpoint: | |
| raise ValueError("异步轮询 endpoint 必须包含 {{task_id}}") | |
| id_path = str(value.get("id_path") or "").strip() | |
| if not id_path or not RESPONSE_PATH_RE.fullmatch(id_path): | |
| raise ValueError("异步轮询 id_path 无效") | |
| try: | |
| interval_seconds = int(value.get("interval_seconds") or 3) | |
| max_attempts = int(value.get("max_attempts") or 100) | |
| except (TypeError, ValueError) as exc: | |
| raise ValueError("异步轮询次数或间隔无效") from exc | |
| return { | |
| "endpoint": endpoint, | |
| "id_path": id_path, | |
| "interval_seconds": max(1, min(30, interval_seconds)), | |
| "max_attempts": max(1, min(180, max_attempts)), | |
| } | |
| def _normalize_adapter_operation(value: object, *, base_url: object = "") -> dict[str, Any]: | |
| raw = value if isinstance(value, dict) else {} | |
| content_type = str(raw.get("content_type") or "json").strip().lower() | |
| if content_type not in ALLOWED_CONTENT_TYPES: | |
| raise ValueError("调用模板 content_type 仅支持 auto/json/multipart") | |
| image_field = str(raw.get("image_field") or "").strip() | |
| if image_field and not re.fullmatch(r"[A-Za-z0-9_.\[\]-]{1,100}", image_field): | |
| raise ValueError("调用模板 image_field 无效") | |
| return { | |
| "endpoint": _normalize_adapter_endpoint(raw.get("endpoint"), base_url=base_url), | |
| "content_type": content_type, | |
| "body": _validate_template_value(raw.get("body") if isinstance(raw.get("body"), dict) else {}), | |
| "image_field": image_field, | |
| "poll": _normalize_poll_config(raw.get("poll"), base_url=base_url), | |
| "response": _normalize_response_config(raw.get("response")), | |
| } | |
| def _normalize_models_operation(value: object, *, base_url: object = "") -> dict[str, str] | None: | |
| if value is None: | |
| return None | |
| if not isinstance(value, dict): | |
| raise ValueError("调用模板 models 必须是对象或 null") | |
| response_path = str(value.get("response_path") or "").strip() | |
| id_path = str(value.get("id_path") or "id").strip() | |
| if response_path and not RESPONSE_PATH_RE.fullmatch(response_path): | |
| raise ValueError("调用模板 models.response_path 无效") | |
| if not id_path or not RESPONSE_PATH_RE.fullmatch(id_path): | |
| raise ValueError("调用模板 models.id_path 无效") | |
| return { | |
| "endpoint": _normalize_adapter_endpoint(value.get("endpoint"), base_url=base_url), | |
| "response_path": response_path, | |
| "id_path": id_path, | |
| } | |
| def normalize_adapter_template(value: object, *, base_url: object = "") -> dict[str, Any]: | |
| if not isinstance(value, dict): | |
| raise ValueError("调用模板不能为空") | |
| method = str(value.get("method") or "POST").strip().upper() | |
| if method != "POST": | |
| raise ValueError("当前仅允许 POST 调用方式") | |
| auth_type = str(value.get("auth_type") or "bearer").strip().lower() | |
| if auth_type not in ALLOWED_AUTH_TYPES: | |
| raise ValueError("调用模板 auth_type 不受支持") | |
| auth_name = str(value.get("auth_name") or "").strip() | |
| if auth_type == "x-api-key": | |
| auth_name = auth_name or "x-api-key" | |
| if not re.fullmatch(r"[A-Za-z0-9_-]{1,80}", auth_name): | |
| raise ValueError("调用模板 auth_name 无效") | |
| elif auth_type == "body": | |
| auth_name = auth_name or "key" | |
| if not re.fullmatch(r"[A-Za-z0-9_.\[\]-]{1,80}", auth_name): | |
| raise ValueError("调用模板 auth_name 无效") | |
| else: | |
| auth_name = "" | |
| if not isinstance(value.get("generate"), dict): | |
| raise ValueError("调用模板缺少文生图 generate 配置") | |
| if "edit" not in value: | |
| raise ValueError("调用模板缺少图生图 edit 配置") | |
| edit_source = value.get("edit") | |
| if edit_source is not None and not isinstance(edit_source, dict): | |
| raise ValueError("调用模板 edit 必须是对象或 null") | |
| generate = _normalize_adapter_operation(value["generate"], base_url=base_url) | |
| edit = _normalize_adapter_operation(edit_source, base_url=base_url) if edit_source is not None else None | |
| models = _normalize_models_operation(value.get("models"), base_url=base_url) | |
| return { | |
| "version": 2, | |
| "method": "POST", | |
| "auth_type": auth_type, | |
| "auth_name": auth_name, | |
| "generate": generate, | |
| "edit": edit, | |
| "models": models, | |
| } | |
| def normalize_adapter_result( | |
| value: object, | |
| *, | |
| base_url: object = "", | |
| auth_hint: object = "", | |
| ) -> dict[str, Any]: | |
| parsed = value if isinstance(value, dict) else {} | |
| template_value = parsed.get("template") if isinstance(parsed.get("template"), dict) else parsed | |
| template = normalize_adapter_template(template_value, base_url=base_url) | |
| if str(auth_hint or "").strip().lower() == "none": | |
| template["auth_type"] = "none" | |
| template["auth_name"] = "" | |
| for operation_name in ("generate", "edit"): | |
| operation = template.get(operation_name) | |
| if not isinstance(operation, dict) or not isinstance(operation.get("body"), dict): | |
| continue | |
| operation["body"] = { | |
| key: item | |
| for key, item in operation["body"].items() | |
| if not ( | |
| SECRET_NAME_RE.search(str(key)) | |
| and isinstance(item, str) | |
| and item.strip() in {"", "{{api_key}}"} | |
| ) | |
| } | |
| models = [] | |
| if isinstance(parsed.get("models"), list): | |
| models = list(dict.fromkeys(str(item).strip() for item in parsed["models"] if str(item).strip()))[:100] | |
| summary = str(parsed.get("summary") or "已根据导入文档生成自定义调用模板").strip()[:500] | |
| return {"summary": summary, "models": models, "template": template} | |