Initial multi-protocol chat vision node
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
api_key.txt
|
||||
__pycache__/
|
||||
@@ -0,0 +1,104 @@
|
||||
# comfyui-FOK_API_tools
|
||||
|
||||
ComfyUI custom nodes for calling text and vision LLM APIs through multiple request protocols.
|
||||
|
||||
## Nodes
|
||||
|
||||
### FOK Multi-Protocol Chat Vision API
|
||||
|
||||
Sends a prompt and up to four images to an API endpoint, then returns the response text and debug info.
|
||||
|
||||
Supported protocol formats:
|
||||
|
||||
- `openai_chat_completions`
|
||||
- `openai_responses`
|
||||
- `anthropic_messages`
|
||||
- `gemini_generatecontent`
|
||||
|
||||
This is useful for native provider APIs and switching/proxy services such as `cc-switch`, where the same backend may accept different API-compatible formats.
|
||||
|
||||
## Installation
|
||||
|
||||
Place this folder under:
|
||||
|
||||
```text
|
||||
ComfyUI/custom_nodes/comfyui-FOK_API_tools
|
||||
```
|
||||
|
||||
Restart ComfyUI. The node should appear under:
|
||||
|
||||
```text
|
||||
FOK API Tools/API
|
||||
```
|
||||
|
||||
## API Key
|
||||
|
||||
Do not put API keys directly into ComfyUI workflows. Workflow files can be exported or shared, so direct key inputs are intentionally not provided.
|
||||
|
||||
Create a local key file in this node directory:
|
||||
|
||||
```text
|
||||
api_key.txt
|
||||
```
|
||||
|
||||
Put only the API key in that file. `api_key.txt` is ignored by Git.
|
||||
|
||||
An empty example file is included:
|
||||
|
||||
```text
|
||||
api_key.txt.example
|
||||
```
|
||||
|
||||
The node input `api_key_file` only accepts a file name in this node directory. Absolute paths and parent directory paths are rejected.
|
||||
|
||||
## Basic Usage
|
||||
|
||||
1. Add `FOK Multi-Protocol Chat Vision API`.
|
||||
2. Select `api_protocol`.
|
||||
3. Set `model`.
|
||||
4. Set `api_base_url`.
|
||||
5. Connect `prompt`.
|
||||
6. Optionally connect up to four images.
|
||||
7. Put your API key in `api_key.txt`.
|
||||
|
||||
## Protocol URLs
|
||||
|
||||
The node appends the protocol endpoint to `api_base_url`:
|
||||
|
||||
| Protocol | Endpoint |
|
||||
| --- | --- |
|
||||
| `openai_chat_completions` | `/chat/completions` |
|
||||
| `openai_responses` | `/responses` |
|
||||
| `anthropic_messages` | `/messages` |
|
||||
| `gemini_generatecontent` | `/models/{model}:generateContent` |
|
||||
|
||||
For Gemini, `api_base_url` can also be a full URL ending with `:generateContent`, or can contain `{model}` as a placeholder.
|
||||
|
||||
## cc-switch Usage
|
||||
|
||||
Set `api_base_url` to the base URL exposed by `cc-switch`, then select the protocol format expected by that route:
|
||||
|
||||
- OpenAI Chat Completions-compatible route: `openai_chat_completions`
|
||||
- OpenAI Responses-compatible route: `openai_responses`
|
||||
- Anthropic Messages-compatible route: `anthropic_messages`
|
||||
- Gemini native GenerateContent-compatible route: `gemini_generatecontent`
|
||||
|
||||
The node sends provider-compatible JSON bodies and image payloads as base64 PNG data.
|
||||
|
||||
## Error Handling
|
||||
|
||||
`on_error` controls failure behavior:
|
||||
|
||||
- `skip`: return the original prompt.
|
||||
- `stop`: block downstream execution when ComfyUI supports `ExecutionBlocker`.
|
||||
- `raise`: raise the error and stop execution.
|
||||
|
||||
## Debug Info
|
||||
|
||||
The second output returns debug JSON with request and response metadata. Authentication headers are redacted:
|
||||
|
||||
- `Authorization`
|
||||
- `x-api-key`
|
||||
- `x-goog-api-key`
|
||||
|
||||
Large payloads may be omitted from debug output to avoid dumping base64 image data.
|
||||
+104
@@ -0,0 +1,104 @@
|
||||
# comfyui-FOK_API_tools
|
||||
|
||||
这是一个 ComfyUI 自定义节点包,用于通过多种请求协议调用文本和视觉大模型 API。
|
||||
|
||||
## 节点
|
||||
|
||||
### FOK Multi-Protocol Chat Vision API
|
||||
|
||||
向 API 端点发送提示词和最多四张图片,然后返回模型回复文本和调试信息。
|
||||
|
||||
支持的协议格式:
|
||||
|
||||
- `openai_chat_completions`
|
||||
- `openai_responses`
|
||||
- `anthropic_messages`
|
||||
- `gemini_generatecontent`
|
||||
|
||||
这个节点适合直接调用原生供应商 API,也适合配合 `cc-switch` 这类切换/代理服务使用。代理后端可以根据不同路由接收不同 API 兼容格式。
|
||||
|
||||
## 安装
|
||||
|
||||
把本目录放到:
|
||||
|
||||
```text
|
||||
ComfyUI/custom_nodes/comfyui-FOK_API_tools
|
||||
```
|
||||
|
||||
重启 ComfyUI。节点会出现在:
|
||||
|
||||
```text
|
||||
FOK API Tools/API
|
||||
```
|
||||
|
||||
## API Key
|
||||
|
||||
不要把 API key 直接写进 ComfyUI 工作流。工作流文件可能会被导出或分享,所以本节点刻意不提供直接填写 key 的输入框。
|
||||
|
||||
请在当前节点目录创建本地密钥文件:
|
||||
|
||||
```text
|
||||
api_key.txt
|
||||
```
|
||||
|
||||
文件里只放 API key。`api_key.txt` 已经被 Git 忽略,不会提交。
|
||||
|
||||
仓库里提供了一个空示例文件:
|
||||
|
||||
```text
|
||||
api_key.txt.example
|
||||
```
|
||||
|
||||
节点输入 `api_key_file` 只接受当前节点目录下的文件名,不允许绝对路径或上级目录路径。
|
||||
|
||||
## 基本用法
|
||||
|
||||
1. 添加 `FOK Multi-Protocol Chat Vision API` 节点。
|
||||
2. 选择 `api_protocol`。
|
||||
3. 填写 `model`。
|
||||
4. 填写 `api_base_url`。
|
||||
5. 连接 `prompt`。
|
||||
6. 可选连接最多四张图片。
|
||||
7. 把 API key 写入 `api_key.txt`。
|
||||
|
||||
## 协议 URL
|
||||
|
||||
节点会根据协议把端点追加到 `api_base_url` 后:
|
||||
|
||||
| 协议 | 端点 |
|
||||
| --- | --- |
|
||||
| `openai_chat_completions` | `/chat/completions` |
|
||||
| `openai_responses` | `/responses` |
|
||||
| `anthropic_messages` | `/messages` |
|
||||
| `gemini_generatecontent` | `/models/{model}:generateContent` |
|
||||
|
||||
Gemini 的 `api_base_url` 也可以直接填写以 `:generateContent` 结尾的完整 URL,或者使用 `{model}` 占位符。
|
||||
|
||||
## cc-switch 用法
|
||||
|
||||
把 `api_base_url` 设置为 `cc-switch` 暴露的 base URL,然后根据该路由期望接收的格式选择协议:
|
||||
|
||||
- OpenAI Chat Completions 兼容路由:`openai_chat_completions`
|
||||
- OpenAI Responses 兼容路由:`openai_responses`
|
||||
- Anthropic Messages 兼容路由:`anthropic_messages`
|
||||
- Gemini 原生 GenerateContent 兼容路由:`gemini_generatecontent`
|
||||
|
||||
节点会发送对应供应商兼容的 JSON 请求体,图片会以 base64 PNG 数据发送。
|
||||
|
||||
## 错误处理
|
||||
|
||||
`on_error` 控制失败时的行为:
|
||||
|
||||
- `skip`:返回原始 prompt。
|
||||
- `stop`:在 ComfyUI 支持 `ExecutionBlocker` 时阻断下游执行。
|
||||
- `raise`:抛出错误并停止执行。
|
||||
|
||||
## 调试信息
|
||||
|
||||
第二个输出会返回调试 JSON,包含请求和响应元信息。鉴权请求头会被脱敏:
|
||||
|
||||
- `Authorization`
|
||||
- `x-api-key`
|
||||
- `x-goog-api-key`
|
||||
|
||||
如果请求体过大,调试信息会省略 payload,避免输出大量 base64 图片数据。
|
||||
+16
@@ -0,0 +1,16 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from .api_nodes import FOKMultiProtocolChatVisionAPI
|
||||
|
||||
|
||||
NODE_CLASS_MAPPINGS = {
|
||||
"FOK_MultiProtocolChatVisionAPI": FOKMultiProtocolChatVisionAPI,
|
||||
}
|
||||
|
||||
NODE_DISPLAY_NAME_MAPPINGS = {
|
||||
"FOK_MultiProtocolChatVisionAPI": "FOK Multi-Protocol Chat Vision API",
|
||||
}
|
||||
|
||||
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"]
|
||||
|
||||
print("\033[34mFOK API Tools: \033[92mLoaded custom nodes.\033[0m")
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
+554
@@ -0,0 +1,554 @@
|
||||
import base64
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
from typing import Any, Dict, List, Optional, Tuple
|
||||
|
||||
import numpy as np
|
||||
import requests
|
||||
from PIL import Image
|
||||
|
||||
try:
|
||||
from comfy_execution.graph_utils import ExecutionBlocker
|
||||
except Exception:
|
||||
ExecutionBlocker = None
|
||||
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
NODE_DIR = os.path.dirname(os.path.abspath(__file__))
|
||||
|
||||
|
||||
PROTOCOL_OPENAI_CHAT = "openai_chat_completions"
|
||||
PROTOCOL_OPENAI_RESPONSES = "openai_responses"
|
||||
PROTOCOL_ANTHROPIC_MESSAGES = "anthropic_messages"
|
||||
PROTOCOL_GEMINI_GENERATE_CONTENT = "gemini_generatecontent"
|
||||
|
||||
API_PROTOCOLS = [
|
||||
PROTOCOL_OPENAI_CHAT,
|
||||
PROTOCOL_OPENAI_RESPONSES,
|
||||
PROTOCOL_ANTHROPIC_MESSAGES,
|
||||
PROTOCOL_GEMINI_GENERATE_CONTENT,
|
||||
]
|
||||
|
||||
PROTOCOL_TOOLTIP = (
|
||||
"Choose the JSON protocol sent to the server. "
|
||||
"openai_chat_completions -> /chat/completions; "
|
||||
"openai_responses -> /responses; "
|
||||
"anthropic_messages -> /messages; "
|
||||
"gemini_generatecontent -> /models/{model}:generateContent. "
|
||||
"For cc-switch, select the format expected by the target route."
|
||||
)
|
||||
|
||||
MODEL_TOOLTIP = (
|
||||
"Model name passed through unchanged, for example gpt-4o, claude-3-5-sonnet-latest, "
|
||||
"gemini-2.5-flash, or the model alias configured in your proxy."
|
||||
)
|
||||
|
||||
BASE_URL_TOOLTIP = (
|
||||
"Base URL for the API or proxy. The node appends the endpoint for the selected protocol. "
|
||||
"Examples: https://api.openai.com/v1, https://api.anthropic.com/v1, "
|
||||
"https://generativelanguage.googleapis.com/v1beta, or your cc-switch base URL. "
|
||||
"For Gemini you may also provide a full URL ending in :generateContent or include {model}."
|
||||
)
|
||||
|
||||
KEY_FILE_TOOLTIP = (
|
||||
"File name only, read from this node directory. Do not enter an API key here. "
|
||||
"Default: api_key.txt. This keeps secrets out of workflow JSON files. "
|
||||
"Absolute paths and ../ are rejected."
|
||||
)
|
||||
|
||||
DEBUG_OUTPUT_TOOLTIP = (
|
||||
"Second output contains request/response debug JSON. Auth headers are redacted, "
|
||||
"and large image payloads may be omitted."
|
||||
)
|
||||
|
||||
|
||||
def read_api_key(api_key_file: str = "api_key.txt") -> str:
|
||||
key_filename = os.path.basename(api_key_file.strip() or "api_key.txt")
|
||||
if key_filename != (api_key_file.strip() or "api_key.txt"):
|
||||
raise ValueError("api_key_file must be a file name in this node directory, not a path.")
|
||||
|
||||
key_path = os.path.join(NODE_DIR, key_filename)
|
||||
if not os.path.exists(key_path):
|
||||
with open(key_path, "w", encoding="utf-8") as key_file:
|
||||
key_file.write("YOUR_API_KEY_HERE")
|
||||
log.warning("API key file not found. Created template at: %s", key_path)
|
||||
return ""
|
||||
|
||||
with open(key_path, "r", encoding="utf-8") as key_file:
|
||||
key = key_file.read().strip()
|
||||
if not key or key == "YOUR_API_KEY_HERE":
|
||||
log.warning("API key is missing or placeholder in %s", key_path)
|
||||
return ""
|
||||
return key
|
||||
|
||||
|
||||
def tensor_to_pil(image: Any) -> Image.Image:
|
||||
if image.ndim == 4:
|
||||
image = image[0]
|
||||
if image.ndim != 3:
|
||||
raise ValueError("IMAGE tensor must be HWC or NHWC.")
|
||||
|
||||
image_np = image.detach().cpu().numpy()
|
||||
image_np = np.clip(image_np * 255.0, 0, 255).astype(np.uint8)
|
||||
if image_np.shape[-1] == 1:
|
||||
return Image.fromarray(image_np[:, :, 0], mode="L")
|
||||
if image_np.shape[-1] == 3:
|
||||
return Image.fromarray(image_np, mode="RGB")
|
||||
if image_np.shape[-1] == 4:
|
||||
return Image.fromarray(image_np, mode="RGBA")
|
||||
raise ValueError(f"Unsupported IMAGE channel count: {image_np.shape[-1]}")
|
||||
|
||||
|
||||
def tensor_to_png_base64(image: Any) -> str:
|
||||
pil_image = tensor_to_pil(image)
|
||||
buffer = io.BytesIO()
|
||||
pil_image.save(buffer, format="PNG")
|
||||
return base64.b64encode(buffer.getvalue()).decode("utf-8")
|
||||
|
||||
|
||||
def collect_image_b64(*images: Optional[Any]) -> List[str]:
|
||||
return [tensor_to_png_base64(image) for image in images if image is not None]
|
||||
|
||||
|
||||
def normalize_base_url(api_base_url: str, default_base_url: str) -> str:
|
||||
base_url = (api_base_url or "").strip() or default_base_url
|
||||
return base_url.rstrip("/")
|
||||
|
||||
|
||||
def redact_headers(headers: Dict[str, str]) -> Dict[str, str]:
|
||||
redacted = dict(headers)
|
||||
for key in list(redacted.keys()):
|
||||
if key.lower() in {"authorization", "x-api-key", "x-goog-api-key"}:
|
||||
redacted[key] = "***"
|
||||
return redacted
|
||||
|
||||
|
||||
def trim_debug_payload(payload: Any) -> Any:
|
||||
text = json.dumps(payload, ensure_ascii=False)
|
||||
if len(text) <= 8000:
|
||||
return payload
|
||||
return {"notice": "Payload omitted because it is large; image data was included in the request."}
|
||||
|
||||
|
||||
def first_text_from_openai_chat(response_json: Dict[str, Any]) -> str:
|
||||
return response_json["choices"][0]["message"].get("content") or ""
|
||||
|
||||
|
||||
def first_text_from_openai_responses(response_json: Dict[str, Any]) -> str:
|
||||
if response_json.get("output_text"):
|
||||
return response_json["output_text"]
|
||||
|
||||
chunks: List[str] = []
|
||||
for item in response_json.get("output", []):
|
||||
for content in item.get("content", []):
|
||||
if content.get("type") in {"output_text", "text"} and content.get("text"):
|
||||
chunks.append(content["text"])
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
def first_text_from_anthropic(response_json: Dict[str, Any]) -> str:
|
||||
chunks: List[str] = []
|
||||
for item in response_json.get("content", []):
|
||||
if item.get("type") == "text" and item.get("text"):
|
||||
chunks.append(item["text"])
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
def first_text_from_gemini(response_json: Dict[str, Any]) -> str:
|
||||
chunks: List[str] = []
|
||||
for candidate in response_json.get("candidates", []):
|
||||
for part in candidate.get("content", {}).get("parts", []):
|
||||
if part.get("text"):
|
||||
chunks.append(part["text"])
|
||||
return "".join(chunks)
|
||||
|
||||
|
||||
class FOKMultiProtocolChatVisionAPI:
|
||||
"""
|
||||
Sends text and up to four images to OpenAI, Anthropic, Gemini, or compatible
|
||||
switching proxies such as cc-switch.
|
||||
"""
|
||||
|
||||
CATEGORY = "FOK API Tools/API"
|
||||
DESCRIPTION = (
|
||||
"Send text and up to four images to OpenAI Chat Completions, OpenAI Responses, "
|
||||
"Anthropic Messages, Gemini GenerateContent, or a compatible proxy such as cc-switch. "
|
||||
"API keys are loaded from a local file instead of being stored in the workflow."
|
||||
)
|
||||
FUNCTION = "execute"
|
||||
RETURN_TYPES = ("STRING", "STRING")
|
||||
RETURN_NAMES = ("response_text", "debug_info")
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"api_protocol": (
|
||||
API_PROTOCOLS,
|
||||
{"default": PROTOCOL_OPENAI_CHAT, "tooltip": PROTOCOL_TOOLTIP},
|
||||
),
|
||||
"model": ("STRING", {"default": "gpt-4o", "tooltip": MODEL_TOOLTIP}),
|
||||
"prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"forceInput": True,
|
||||
"tooltip": "User prompt sent with the optional images. Connect a text node here.",
|
||||
},
|
||||
),
|
||||
"api_base_url": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "https://api.openai.com/v1",
|
||||
"tooltip": BASE_URL_TOOLTIP,
|
||||
},
|
||||
),
|
||||
"on_error": (
|
||||
["skip", "stop", "raise"],
|
||||
{
|
||||
"default": "skip",
|
||||
"tooltip": (
|
||||
"Failure behavior. skip returns the original prompt; stop blocks downstream nodes "
|
||||
"when ExecutionBlocker is available; raise throws the error and stops execution."
|
||||
),
|
||||
},
|
||||
),
|
||||
"timeout": (
|
||||
"INT",
|
||||
{
|
||||
"default": 120,
|
||||
"min": 10,
|
||||
"max": 600,
|
||||
"step": 10,
|
||||
"tooltip": "HTTP request timeout in seconds. Increase for slow models or image-heavy requests.",
|
||||
},
|
||||
),
|
||||
},
|
||||
"optional": {
|
||||
"system_prompt": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": True,
|
||||
"default": "",
|
||||
"tooltip": (
|
||||
"Optional system/instruction text. Mapped to system, instructions, "
|
||||
"or systemInstruction depending on the selected protocol."
|
||||
),
|
||||
},
|
||||
),
|
||||
"image_1": ("IMAGE", {"tooltip": "Optional first image. Sent as PNG base64."}),
|
||||
"image_2": ("IMAGE", {"tooltip": "Optional second image. Sent as PNG base64."}),
|
||||
"image_3": ("IMAGE", {"tooltip": "Optional third image. Sent as PNG base64."}),
|
||||
"image_4": ("IMAGE", {"tooltip": "Optional fourth image. Sent as PNG base64."}),
|
||||
"max_tokens": (
|
||||
"INT",
|
||||
{
|
||||
"default": 2048,
|
||||
"min": 1,
|
||||
"max": 32768,
|
||||
"step": 1,
|
||||
"tooltip": (
|
||||
"Maximum text tokens to generate. Sent as max_tokens for Chat/Anthropic, "
|
||||
"max_output_tokens for Responses, and maxOutputTokens for Gemini."
|
||||
),
|
||||
},
|
||||
),
|
||||
"temperature": (
|
||||
"FLOAT",
|
||||
{
|
||||
"default": 0.7,
|
||||
"min": 0.0,
|
||||
"max": 2.0,
|
||||
"step": 0.05,
|
||||
"tooltip": "Sampling temperature. Lower is more deterministic; higher is more varied.",
|
||||
},
|
||||
),
|
||||
"api_key_file": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "api_key.txt",
|
||||
"tooltip": KEY_FILE_TOOLTIP,
|
||||
},
|
||||
),
|
||||
"anthropic_version": (
|
||||
"STRING",
|
||||
{
|
||||
"multiline": False,
|
||||
"default": "2023-06-01",
|
||||
"tooltip": (
|
||||
"Anthropic API version header used only for anthropic_messages. "
|
||||
"Leave default unless your proxy or Anthropic API requires another value."
|
||||
),
|
||||
},
|
||||
),
|
||||
},
|
||||
}
|
||||
|
||||
def execute(
|
||||
self,
|
||||
api_protocol: str,
|
||||
model: str,
|
||||
prompt: str,
|
||||
api_base_url: str,
|
||||
on_error: str,
|
||||
timeout: int,
|
||||
system_prompt: str = "",
|
||||
image_1: Optional[Any] = None,
|
||||
image_2: Optional[Any] = None,
|
||||
image_3: Optional[Any] = None,
|
||||
image_4: Optional[Any] = None,
|
||||
max_tokens: int = 2048,
|
||||
temperature: float = 0.7,
|
||||
api_key_file: str = "api_key.txt",
|
||||
anthropic_version: str = "2023-06-01",
|
||||
) -> Tuple[Any, str]:
|
||||
debug_data: Dict[str, Any] = {"request": {}, "response": {}}
|
||||
|
||||
try:
|
||||
if api_protocol not in API_PROTOCOLS:
|
||||
raise ValueError(f"Unsupported api_protocol: {api_protocol}")
|
||||
if not model.strip():
|
||||
raise ValueError("Model ID is required.")
|
||||
|
||||
key = read_api_key(api_key_file)
|
||||
image_b64_list = collect_image_b64(image_1, image_2, image_3, image_4)
|
||||
|
||||
url, headers, payload = self.build_request(
|
||||
api_protocol=api_protocol,
|
||||
model=model.strip(),
|
||||
prompt=prompt,
|
||||
system_prompt=system_prompt,
|
||||
api_base_url=api_base_url,
|
||||
api_key=key,
|
||||
image_b64_list=image_b64_list,
|
||||
max_tokens=max_tokens,
|
||||
temperature=temperature,
|
||||
anthropic_version=anthropic_version,
|
||||
)
|
||||
|
||||
debug_data["request"] = {
|
||||
"url": url,
|
||||
"method": "POST",
|
||||
"headers": redact_headers(headers),
|
||||
"payload": trim_debug_payload(payload),
|
||||
}
|
||||
|
||||
response = requests.post(url, headers=headers, json=payload, timeout=timeout)
|
||||
debug_data["response"]["status_code"] = response.status_code
|
||||
response.raise_for_status()
|
||||
|
||||
response_json = response.json()
|
||||
debug_data["response"]["body"] = response_json
|
||||
response_text = self.parse_response(api_protocol, response_json)
|
||||
if not response_text:
|
||||
raise RuntimeError("API returned an empty text response.")
|
||||
return (response_text, json.dumps(debug_data, indent=2, ensure_ascii=False))
|
||||
|
||||
except Exception as exc:
|
||||
log.error("%s failed: %s", self.__class__.__name__, exc)
|
||||
if isinstance(exc, requests.exceptions.RequestException) and exc.response is not None:
|
||||
debug_data["response"]["status_code"] = exc.response.status_code
|
||||
debug_data["response"]["body"] = exc.response.text
|
||||
debug_data["error"] = str(exc)
|
||||
debug_json = json.dumps(debug_data, indent=2, ensure_ascii=False)
|
||||
|
||||
if on_error == "stop":
|
||||
if ExecutionBlocker is None:
|
||||
return ("", debug_json)
|
||||
return (ExecutionBlocker(None), debug_json)
|
||||
if on_error == "skip":
|
||||
return (prompt, debug_json)
|
||||
raise
|
||||
|
||||
def build_request(
|
||||
self,
|
||||
api_protocol: str,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
image_b64_list: List[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
anthropic_version: str,
|
||||
) -> Tuple[str, Dict[str, str], Dict[str, Any]]:
|
||||
if api_protocol == PROTOCOL_OPENAI_CHAT:
|
||||
return self.build_openai_chat_request(
|
||||
model, prompt, system_prompt, api_base_url, api_key, image_b64_list, max_tokens, temperature
|
||||
)
|
||||
if api_protocol == PROTOCOL_OPENAI_RESPONSES:
|
||||
return self.build_openai_responses_request(
|
||||
model, prompt, system_prompt, api_base_url, api_key, image_b64_list, max_tokens, temperature
|
||||
)
|
||||
if api_protocol == PROTOCOL_ANTHROPIC_MESSAGES:
|
||||
return self.build_anthropic_messages_request(
|
||||
model,
|
||||
prompt,
|
||||
system_prompt,
|
||||
api_base_url,
|
||||
api_key,
|
||||
image_b64_list,
|
||||
max_tokens,
|
||||
temperature,
|
||||
anthropic_version,
|
||||
)
|
||||
return self.build_gemini_generate_content_request(
|
||||
model, prompt, system_prompt, api_base_url, api_key, image_b64_list, max_tokens, temperature
|
||||
)
|
||||
|
||||
def build_openai_chat_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
image_b64_list: List[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Tuple[str, Dict[str, str], Dict[str, Any]]:
|
||||
url = f"{normalize_base_url(api_base_url, 'https://api.openai.com/v1')}/chat/completions"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
messages: List[Dict[str, Any]] = []
|
||||
if system_prompt.strip():
|
||||
messages.append({"role": "system", "content": system_prompt})
|
||||
|
||||
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for image_b64 in image_b64_list:
|
||||
content.append({"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image_b64}"}})
|
||||
messages.append({"role": "user", "content": content})
|
||||
|
||||
payload = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
return url, headers, payload
|
||||
|
||||
def build_openai_responses_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
image_b64_list: List[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Tuple[str, Dict[str, str], Dict[str, Any]]:
|
||||
url = f"{normalize_base_url(api_base_url, 'https://api.openai.com/v1')}/responses"
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["Authorization"] = f"Bearer {api_key}"
|
||||
|
||||
content: List[Dict[str, Any]] = [{"type": "input_text", "text": prompt}]
|
||||
for image_b64 in image_b64_list:
|
||||
content.append({"type": "input_image", "image_url": f"data:image/png;base64,{image_b64}"})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"input": [{"role": "user", "content": content}],
|
||||
"max_output_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if system_prompt.strip():
|
||||
payload["instructions"] = system_prompt
|
||||
return url, headers, payload
|
||||
|
||||
def build_anthropic_messages_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
image_b64_list: List[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
anthropic_version: str,
|
||||
) -> Tuple[str, Dict[str, str], Dict[str, Any]]:
|
||||
url = f"{normalize_base_url(api_base_url, 'https://api.anthropic.com/v1')}/messages"
|
||||
headers = {
|
||||
"Content-Type": "application/json",
|
||||
"anthropic-version": anthropic_version.strip() or "2023-06-01",
|
||||
}
|
||||
if api_key:
|
||||
headers["x-api-key"] = api_key
|
||||
|
||||
content: List[Dict[str, Any]] = [{"type": "text", "text": prompt}]
|
||||
for image_b64 in image_b64_list:
|
||||
content.append(
|
||||
{
|
||||
"type": "image",
|
||||
"source": {
|
||||
"type": "base64",
|
||||
"media_type": "image/png",
|
||||
"data": image_b64,
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": [{"role": "user", "content": content}],
|
||||
"max_tokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
}
|
||||
if system_prompt.strip():
|
||||
payload["system"] = system_prompt
|
||||
return url, headers, payload
|
||||
|
||||
def build_gemini_generate_content_request(
|
||||
self,
|
||||
model: str,
|
||||
prompt: str,
|
||||
system_prompt: str,
|
||||
api_base_url: str,
|
||||
api_key: str,
|
||||
image_b64_list: List[str],
|
||||
max_tokens: int,
|
||||
temperature: float,
|
||||
) -> Tuple[str, Dict[str, str], Dict[str, Any]]:
|
||||
base_url = normalize_base_url(api_base_url, "https://generativelanguage.googleapis.com/v1beta")
|
||||
if "{model}" in base_url:
|
||||
url = base_url.replace("{model}", model)
|
||||
elif base_url.endswith(":generateContent"):
|
||||
url = base_url
|
||||
else:
|
||||
url = f"{base_url}/models/{model}:generateContent"
|
||||
|
||||
headers = {"Content-Type": "application/json"}
|
||||
if api_key:
|
||||
headers["x-goog-api-key"] = api_key
|
||||
|
||||
parts: List[Dict[str, Any]] = [{"text": prompt}]
|
||||
for image_b64 in image_b64_list:
|
||||
parts.append({"inline_data": {"mime_type": "image/png", "data": image_b64}})
|
||||
|
||||
payload: Dict[str, Any] = {
|
||||
"contents": [{"role": "user", "parts": parts}],
|
||||
"generationConfig": {
|
||||
"maxOutputTokens": max_tokens,
|
||||
"temperature": temperature,
|
||||
},
|
||||
}
|
||||
if system_prompt.strip():
|
||||
payload["systemInstruction"] = {"parts": [{"text": system_prompt}]}
|
||||
return url, headers, payload
|
||||
|
||||
def parse_response(self, api_protocol: str, response_json: Dict[str, Any]) -> str:
|
||||
if api_protocol == PROTOCOL_OPENAI_CHAT:
|
||||
return first_text_from_openai_chat(response_json)
|
||||
if api_protocol == PROTOCOL_OPENAI_RESPONSES:
|
||||
return first_text_from_openai_responses(response_json)
|
||||
if api_protocol == PROTOCOL_ANTHROPIC_MESSAGES:
|
||||
return first_text_from_anthropic(response_json)
|
||||
return first_text_from_gemini(response_json)
|
||||
Reference in New Issue
Block a user