From aebc2ae8bbe5df1653fb89d8d465847e5d147949 Mon Sep 17 00:00:00 2001 From: "DESKTOP-TVBJISQ\\Primere" Date: Mon, 2 Mar 2026 14:36:41 +0100 Subject: [PATCH] V 2.0.0 - Universal api #45 - BFL --- Nodes/Uniapi.py | 25 +++++- components/API/api_json_to_requestbody.py | 63 ++++++++++++- components/API/external_api_backend.py | 15 ++++ components/API/responses/Blackforest_Image.py | 89 +++++++++++++++++++ front_end/api_schemas.json | 16 ++-- terminal_helpers/api_snippet_to_json.py | 59 ++++++++++-- terminal_helpers/result.json | 27 ++++-- terminal_helpers/snippet.py | 2 +- 8 files changed, 270 insertions(+), 26 deletions(-) create mode 100644 components/API/responses/Blackforest_Image.py diff --git a/Nodes/Uniapi.py b/Nodes/Uniapi.py index ec9a10a..5aeee41 100644 --- a/Nodes/Uniapi.py +++ b/Nodes/Uniapi.py @@ -127,6 +127,20 @@ class PrimereApiProcessor: if api_provider == "Gemini": gemini_image_data = Image.open(TEMP_FILE_REF) img_binary_api.append(gemini_image_data) + if api_provider == "BlackForest": + encoded_image = None + single_image = source_images[0] + width_original = single_image.shape[2] + height_original = single_image.shape[1] + image_np_bf = (single_image.numpy() * 255).astype(np.uint8) + img_bf = Image.fromarray(image_np_bf) + img_byte_arr_bf = io.BytesIO() + img_bf.save(img_byte_arr_bf, format="PNG") + img_byte_arr_bf.seek(0) + encoded_string = base64.b64encode(img_byte_arr_bf.read()) + img_binary_api = encoded_string.decode('ascii') + break + elif hasattr(loaded_client_for_upload, "upload_file"): uploaded_reference = loaded_client_for_upload.upload_file(TEMP_FILE_REF) img_binary_api.append(uploaded_reference) @@ -200,11 +214,20 @@ class PrimereApiProcessor: try: if rendered.method.upper() == "SDK": - context = {"client": client} + provider_config = config_json.get(api_provider, {}) if isinstance(config_json, dict) else {} + provider_api_key = provider_config.get("APIKEY") if isinstance(provider_config, dict) else None + context = {"client": client, "provider_api_key": provider_api_key} allowed_roots = {"client"} imported_context, imported_roots = external_api_backend.load_import_modules(schema_import_modules) context.update(imported_context) allowed_roots.update(imported_roots) + + auto_context, auto_roots = external_api_backend.build_sdk_context(rendered, client) + for root_name in auto_roots: + if root_name not in context and root_name in auto_context: + context[root_name] = auto_context[root_name] + allowed_roots.add(root_name) + sdk_context = dict(context) sdk_call_data = rendered.sdk_call if isinstance(rendered.sdk_call, dict) else {} sdk_args = sdk_call_data.get("args", []) if isinstance(sdk_call_data, dict) else [] diff --git a/components/API/api_json_to_requestbody.py b/components/API/api_json_to_requestbody.py index e49a90b..6b21d5e 100644 --- a/components/API/api_json_to_requestbody.py +++ b/components/API/api_json_to_requestbody.py @@ -1,6 +1,8 @@ from __future__ import annotations from typing import Any +import os from . import external_api_backend +from . import api_helper def _canonical_param_name(name: str) -> str: low = name.lower() @@ -10,12 +12,62 @@ def _canonical_param_name(name: str) -> str: return "resolution" if low == "model" or low.endswith("_model"): return "model" - if "prompt" in low or "contents" in low: + if low in {"prompt", "contents"} or low.endswith("_prompt"): return "prompt" if "response_modalities" in low: return "response_modalities" return name +def _provider_api_key(spec: dict[str, Any]) -> Any: + provider = str(spec.get("provider") or "").strip() + if not provider: + return None + try: + config = api_helper.get_api_config("apiconfig.json") + except Exception: + return None + entry = config.get(provider, {}) if isinstance(config, dict) else {} + if not isinstance(entry, dict): + return None + value = entry.get("APIKEY") + return value if value not in (None, "") else None + + +def _secret_placeholder_default(key: str, spec: dict[str, Any]) -> Any: + normalized = str(key or "").strip() + low = normalized.lower() + + # If placeholder itself looks like an env-var token, keep token name. + # This preserves schemas that intentionally use: + # {"$call": "os.environ.get", "$args": ["{{BFL_API_KEY}}"]} + # so runtime executes os.environ.get("BFL_API_KEY") correctly. + if normalized.upper() == normalized and "_" in normalized: + if low.endswith(("_key", "_token", "_secret", "_password")): + return normalized + + direct_env = os.environ.get(normalized) or os.environ.get(normalized.upper()) + if direct_env not in (None, ""): + return direct_env + + if low.endswith("_api_key") or low in {"api_key", "apikey", "x_api_key", "provider_api_key", "authorization", "auth_token", "access_token", "bearer_token", "token"}: + return _provider_api_key(spec) + + return None + +def _marker_default(marker: Any) -> Any: + if not isinstance(marker, str): + return None + value = marker.strip().upper() + if value == "INT": + return 1 + if value == "FLOAT": + return 1.0 + if value == "STRING": + return "" + if value == "BOOLEAN": + return False + return None + def _default_value(name: str) -> Any: k = name.lower() if "prompt" in k: @@ -62,7 +114,14 @@ def _build_values(spec: dict[str, Any], values: dict[str, Any] | None = None) -> elif canonical in possible_parameters and isinstance(possible_parameters[canonical], list) and len(possible_parameters[canonical]) > 0: selected = possible_parameters[canonical][0] else: - selected = _default_value(canonical) + secret_selected = _secret_placeholder_default(key, spec) + if secret_selected is not None: + selected = secret_selected + else: + marker_selected = _marker_default(possible_parameters.get(key)) if key in possible_parameters else None + if marker_selected is None and canonical in possible_parameters: + marker_selected = _marker_default(possible_parameters.get(canonical)) + selected = marker_selected if marker_selected is not None else _default_value(canonical) resolved[key] = selected diff --git a/components/API/external_api_backend.py b/components/API/external_api_backend.py index 2146a99..9553eb2 100644 --- a/components/API/external_api_backend.py +++ b/components/API/external_api_backend.py @@ -229,6 +229,20 @@ def _materialize_sdk_value(value: Any, context: dict[str, Any], allowed_roots: s return {k: _materialize_sdk_value(v, context, allowed_roots) for k, v in value.items()} return value +def _apply_auth_header_fallback(kwargs: dict[str, Any], context: dict[str, Any]) -> None: + headers = kwargs.get("headers") if isinstance(kwargs, dict) else None + if not isinstance(headers, dict): + return + + provider_api_key = context.get("provider_api_key") + if provider_api_key in (None, ""): + return + + for auth_key in ("x-key", "x_api_key", "api-key", "authorization", "Authorization"): + if auth_key not in headers: + continue + if headers.get(auth_key) in (None, "", "null"): + headers[auth_key] = provider_api_key def execute_sdk_request(rendered: RenderResult, context: dict[str, Any], allowed_roots: set[str] | None = None) -> Any: if rendered.method.upper() != "SDK": @@ -238,6 +252,7 @@ def execute_sdk_request(rendered: RenderResult, context: dict[str, Any], allowed args, kwargs = normalize_sdk_call(rendered.sdk_call) safe_args = [_materialize_sdk_value(a, context, roots) for a in args] safe_kwargs = {k: _materialize_sdk_value(v, context, roots) for k, v in kwargs.items()} + _apply_auth_header_fallback(safe_kwargs, context) return fn(*safe_args, **safe_kwargs) def default_provider_service(node_data): diff --git a/components/API/responses/Blackforest_Image.py b/components/API/responses/Blackforest_Image.py new file mode 100644 index 0000000..513a9e2 --- /dev/null +++ b/components/API/responses/Blackforest_Image.py @@ -0,0 +1,89 @@ +from __future__ import annotations + +from typing import Any +import json +import requests +import comfy.utils +import numpy as np +import torch +from PIL import Image +import fal_client +from io import BytesIO +import time +from urllib.parse import urlparse + +def handle_response(api_result, schema=None, loaded_client=None, response_url=None): + result_image = None + image_list = [] + final_batch_img = [] + status_accepted = ['Ready', 'Pending', 'Task not found'] + parsed_url = urlparse(response_url) + blackforest_api_region = parsed_url.netloc or None + path_parts = [part for part in parsed_url.path.split("/") if part] + blackforest_api_version = path_parts[0] if len(path_parts) > 0 else None + + try: + json_object = json.loads(api_result.text) + except ValueError as e: + raise RuntimeError(f"Input object failed: {api_result}") + + if 'polling_url' in json_object: + resp = requests.get(json_object['polling_url']) + resp_json_object = json.loads(resp.text) + + status = 'Start' + request_tryout = 0 + error_tryout = 0 + + while error_tryout <= 1: + while status != 'Ready' or request_tryout <= 20: + url_res = f"https://{blackforest_api_region}/{blackforest_api_version}/get_result" + querystring = {"id": resp_json_object['id']} + response = requests.request("GET", url_res, params=querystring) + resp_json_object = json.loads(response.text) + status = resp_json_object['status'] + if status not in status_accepted: + resp_error = requests.get(json_object['polling_url']) + resp_error_json_object = json.loads(resp_error.text) + error_status = resp_error_json_object['status'] + raise RuntimeError(f"Response status: {status}, error status: {error_status}") + if status == 'Ready': + break + time.sleep(2) + request_tryout = request_tryout + 1 + time.sleep(1) + if status == 'Ready': + break + error_tryout = error_tryout + 1 + + if status == 'Ready': + image_url = resp_json_object['result']['sample'] + response = requests.get(image_url) + result_image = Image.open(BytesIO(response.content)) + if result_image is not None: + result_image = result_image.convert("RGB") + result_image = np.array(result_image).astype(np.float32) / 255.0 + result_image = torch.from_numpy(result_image)[None,] + final_batch_img.append(result_image) + else: + raise RuntimeError(f"No result image...") + else: + raise RuntimeError(f"No polling_url in response: {json_object}") + + if type(final_batch_img).__name__ == "list" and len(final_batch_img) > 1: + image_list = final_batch_img + single_image_start = final_batch_img[0] + batch_count = 0 + s = None + for single_image in final_batch_img: + if (batch_count + 1) < len(final_batch_img): + current_single_image = final_batch_img[batch_count + 1] + if single_image_start.shape[1:] != current_single_image.shape[1:]: + current_single_image = comfy.utils.common_upscale(current_single_image.movedim(-1, 1), single_image_start.shape[2], single_image_start.shape[1], "bilinear", "center").movedim(1, -1) + batch_count = batch_count + 1 + if s is not None: + single_image = s + s = torch.cat((current_single_image, single_image), dim=0) + result_image = s + + return result_image \ No newline at end of file diff --git a/front_end/api_schemas.json b/front_end/api_schemas.json index b5bd3a6..275193a 100644 --- a/front_end/api_schemas.json +++ b/front_end/api_schemas.json @@ -385,14 +385,17 @@ } }, "BlackForest": { - "flux-2-klein-4b": { + "Flux": { "provider": "BlackForest", - "service": "flux-2-klein-4b", + "service": "Flux", "response_handler": "Blackforest_Image.py", "import_modules": [ ], "possible_parameters": { - "safety_tolerance": [0, 1, 2, 3, 4, 5], + "model_name": ["flux-kontext-pro", "flux-kontext-max", "flux-pro-1.0-fill", "flux-pro-1.0-expand", "flux-pro", "flux-pro-1.1", "flux-pro-1.1-ultra", "flux-2-flex", "flux-2-pro", "flux-2-max", "flux-2-klein-4b", "flux-2-klein-9b"], + "regions": ["api.bfl.ai", "api.eu.bfl.ai", "api.us.bfl.ai"], + "version": ["v1"], + "safety_tolerance": [0, 1, 2, 3, 4, 5, 6], "prompt_upsampling": [false, true], "guidance": "FLOAT", "steps": "INT" @@ -402,23 +405,20 @@ "endpoint": "requests.post", "sdk_call": { "args": [ - "https://api.bfl.ai/v1/flux-2-klein-4b" + "https://{{regions}}/{{version}}/{{model_name}}" ], "kwargs": { "headers": { "accept": "application/json", "x-key": { "$call": "os.environ.get", - "$args": [ - "{{BFL_API_KEY}}" - ], + "$args": ["{{BFL_API_KEY}}"], "$kwargs": {} }, "Content-Type": "application/json" }, "json": { "output_format": "png", - "image_prompt": "{{reference_images}}", "safety_tolerance": "{{safety_tolerance}}", "prompt": "{{prompt}}", "seed": "{{seed}}", diff --git a/terminal_helpers/api_snippet_to_json.py b/terminal_helpers/api_snippet_to_json.py index e18200d..52530c0 100644 --- a/terminal_helpers/api_snippet_to_json.py +++ b/terminal_helpers/api_snippet_to_json.py @@ -29,8 +29,11 @@ PLACEHOLDER_ALIASES = {"number_of_images": "seed", "aspectRatio": "aspect_ratio" KNOWN_PARAM_OPTIONS: dict[str, list[str]] = { "model": ["example-model-1", "example-model-2"], "resolution": ["1K", "2K", "4K"], + "regions": ["api.bfl.ai", "api.eu.bfl.ai", "api.us.bfl.ai"], } +TYPE_MARKERS = {"INT", "FLOAT", "STRING", "BOOLEAN"} +INLINE_PLACEHOLDER_RE = re.compile(r"(? str: clean = clean.replace("__", "_") return f"{{{{{clean.strip('_') or 'value'}}}}}" +def normalize_inline_placeholders(value: str) -> str: + return INLINE_PLACEHOLDER_RE.sub(lambda m: placeholder(m.group(1)), value) + def node_to_template(node: ast.AST, path: str) -> Any: if isinstance(node, ast.Constant): if node.value is None or isinstance(node.value, bool): return node.value if isinstance(node.value, str): - if node.value in {"INT", "FLOAT", "STRING"}: + if node.value in TYPE_MARKERS: return placeholder(path) + return normalize_inline_placeholders(node.value) return node.value if isinstance(node, ast.Name): @@ -136,7 +143,7 @@ def _collect_placeholders(node: Any) -> set[str]: def walk(item: Any) -> None: if isinstance(item, dict): for k, v in item.items(): - if k in {"$args", "args"}: + if k in {"$args"}: continue walk(v) return @@ -162,21 +169,59 @@ def _canonical_param_name(name: str) -> str: return "model" if low == "number_of_images": return "seed" - if "prompt" in low or "contents" in low: + if low in {"prompt", "contents"} or low.endswith("_prompt"): return "prompt" if "response_modalities" in low: return "response_modalities" return name +def _collect_type_markers(node: ast.AST) -> dict[str, str]: + marked: dict[str, str] = {} -def build_possible_parameters(request_schema: dict[str, Any]) -> dict[str, list[str]]: + def walk(item: ast.AST) -> None: + if isinstance(item, ast.Dict): + for key_node, value_node in zip(item.keys, item.values): + if isinstance(key_node, ast.Constant) and isinstance(key_node.value, str): + key = key_node.value + else: + key = ast.unparse(key_node) + + if isinstance(value_node, ast.Constant) and isinstance(value_node.value, str) and value_node.value in TYPE_MARKERS: + marked[key] = value_node.value + + walk(value_node) + return + + if isinstance(item, (ast.List, ast.Tuple, ast.Set)): + for child in item.elts: + walk(child) + return + + if isinstance(item, ast.Call): + for child in item.args: + walk(child) + for kw in item.keywords: + walk(kw.value) + + walk(node) + return marked + +def build_possible_parameters(request_schema: dict[str, Any], type_markers: dict[str, str] | None = None) -> dict[str, Any]: placeholders = sorted(_collect_placeholders(request_schema)) - possible: dict[str, list[str]] = {} + possible: dict[str, Any] = {} + marker_map = {_canonical_param_name(k): v for k, v in (type_markers or {}).items()} for name in placeholders: canonical = _canonical_param_name(name) if canonical in EXCLUDED_PARAMETER_KEYS: continue + marker = marker_map.get(canonical) + if marker == "BOOLEAN": + possible[canonical] = [False, True] + continue + if marker in {"INT", "FLOAT", "STRING"}: + possible[canonical] = marker + continue if canonical in KNOWN_PARAM_OPTIONS: possible[canonical] = KNOWN_PARAM_OPTIONS[canonical] elif canonical not in possible: @@ -234,12 +279,14 @@ def build_service_schema(snippet: str, provider: str = DEFAULT_PROVIDER, service }, } + type_markers = _collect_type_markers(call) + service_schema = { "provider": provider, "service": service, "response_handler": response_handler_filename(provider, service), "import_modules": build_import_modules(snippet, provider=provider), - "possible_parameters": build_possible_parameters(request_schema), + "possible_parameters": build_possible_parameters(request_schema, type_markers=type_markers), "request": request_schema, } diff --git a/terminal_helpers/result.json b/terminal_helpers/result.json index 4209cc8..bfd2cbc 100644 --- a/terminal_helpers/result.json +++ b/terminal_helpers/result.json @@ -9,17 +9,28 @@ "from your_provider_sdk import types" ], "possible_parameters": { - "guidance": [ - "fake_guidance_value_1", - "fake_guidance_value_2" + "guidance": "FLOAT", + "model_name": [ + "fake_model_name_value_1", + "fake_model_name_value_2" + ], + "prompt_upsampling": [ + false, + true + ], + "regions": [ + "api.bfl.ai", + "api.eu.bfl.ai", + "api.us.bfl.ai" ], "safety_tolerance": [ "fake_safety_tolerance_value_1", "fake_safety_tolerance_value_2" ], - "steps": [ - "fake_steps_value_1", - "fake_steps_value_2" + "steps": "INT", + "version": [ + "fake_version_value_1", + "fake_version_value_2" ] }, "request": { @@ -27,7 +38,7 @@ "endpoint": "requests.post", "sdk_call": { "args": [ - "https://api.bfl.ai/v1/flux-2-klein-4b" + "https://{{regions}}/{{version}}/{{model_name}}" ], "kwargs": { "headers": { @@ -49,7 +60,7 @@ "seed": "{{seed}}", "width": "{{width}}", "height": "{{height}}", - "prompt_upsampling": "BOOLEAN", + "prompt_upsampling": "{{prompt_upsampling}}", "guidance": "{{guidance}}", "steps": "{{steps}}" } diff --git a/terminal_helpers/snippet.py b/terminal_helpers/snippet.py index ee83106..1007f80 100644 --- a/terminal_helpers/snippet.py +++ b/terminal_helpers/snippet.py @@ -1,4 +1,4 @@ -response = requests.post("https://api.bfl.ai/v1/flux-2-klein-4b", +response = requests.post("https://{{regions}}/{{version}}/{{model_name}}", headers={ "accept": "application/json", "x-key": os.environ.get("BFL_API_KEY"),