From 1b3e288e2ca0716dee8ab27b4bde74b7900aab7f Mon Sep 17 00:00:00 2001 From: nuck Date: Thu, 22 Jan 2026 00:32:10 -0500 Subject: [PATCH] Initial release v1.0.0 ComfyUI API Liberation - Use your own API keys directly with vendors. Features: - Direct API routing for 20+ providers (Google, OpenAI, Stability, etc.) - No ComfyUI account required - Local asset vault - images/videos stored locally - API key management UI in ComfyUI settings - Environment variable and config file support - Auto-detection of unmapped proxy endpoints - Graceful fallback when keys not configured --- .gitignore | 39 +++ CHANGELOG.md | 40 +++ LICENSE | 21 ++ README.md | 115 +++++++ __init__.py | 78 +++++ asset_vault.py | 121 ++++++++ config.py | 109 +++++++ discovery.py | 95 ++++++ mappings/__init__.py | 77 +++++ mappings/bfl.py | 46 +++ mappings/byteplus.py | 25 ++ mappings/google.py | 152 ++++++++++ mappings/ideogram.py | 25 ++ mappings/kling.py | 86 ++++++ mappings/ltx.py | 18 ++ mappings/luma.py | 25 ++ mappings/minimax.py | 25 ++ mappings/moonvalley.py | 46 +++ mappings/openai.py | 56 ++++ mappings/pika.py | 69 +++++ mappings/pixverse.py | 39 +++ mappings/recraft.py | 60 ++++ mappings/rodin.py | 25 ++ mappings/runway.py | 32 ++ mappings/stability.py | 72 +++++ mappings/topaz.py | 54 ++++ mappings/tripo.py | 18 ++ mappings/vidu.py | 39 +++ mappings/wan.py | 32 ++ nodes.py | 148 +++++++++ package-lock.json | 29 ++ package.json | 13 + patches.py | 217 +++++++++++++ router.py | 161 ++++++++++ routes.py | 95 ++++++ src/web/liberation.ts | 669 +++++++++++++++++++++++++++++++++++++++++ tsconfig.json | 18 ++ web/liberation.css | 368 +++++++++++++++++++++++ web/liberation.js | 560 ++++++++++++++++++++++++++++++++++ 39 files changed, 3917 insertions(+) create mode 100644 .gitignore create mode 100644 CHANGELOG.md create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 asset_vault.py create mode 100644 config.py create mode 100644 discovery.py create mode 100644 mappings/__init__.py create mode 100644 mappings/bfl.py create mode 100644 mappings/byteplus.py create mode 100644 mappings/google.py create mode 100644 mappings/ideogram.py create mode 100644 mappings/kling.py create mode 100644 mappings/ltx.py create mode 100644 mappings/luma.py create mode 100644 mappings/minimax.py create mode 100644 mappings/moonvalley.py create mode 100644 mappings/openai.py create mode 100644 mappings/pika.py create mode 100644 mappings/pixverse.py create mode 100644 mappings/recraft.py create mode 100644 mappings/rodin.py create mode 100644 mappings/runway.py create mode 100644 mappings/stability.py create mode 100644 mappings/topaz.py create mode 100644 mappings/tripo.py create mode 100644 mappings/vidu.py create mode 100644 mappings/wan.py create mode 100644 nodes.py create mode 100644 package-lock.json create mode 100644 package.json create mode 100644 patches.py create mode 100644 router.py create mode 100644 routes.py create mode 100644 src/web/liberation.ts create mode 100644 tsconfig.json create mode 100644 web/liberation.css create mode 100644 web/liberation.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..18d3f72 --- /dev/null +++ b/.gitignore @@ -0,0 +1,39 @@ +# Secrets - NEVER commit API keys +api_keys.json +.env +.env.* + +# Python +__pycache__/ +*.py[cod] +*$py.class +*.so +.Python +.mypy_cache/ +.ruff_cache/ +*.egg-info/ +dist/ +build/ + +# Node +node_modules/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS +.DS_Store +Thumbs.db + +# Testing +.coverage +htmlcov/ +.pytest_cache/ + +# Local development +*.local +tmp/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..76508ee --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,40 @@ +# Changelog + +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). + +## [1.0.0] - 2025-01-11 + +### Added + +- Initial release +- Direct API routing for 20+ providers: + - Google (Gemini, Veo, Imagen) + - OpenAI (GPT, DALL-E, Sora) + - Stability AI + - BFL (Flux) + - Ideogram + - Recraft + - Luma Labs + - Runway + - Kling AI + - MiniMax + - Pika + - Tripo3D + - Rodin + - Topaz Labs + - BytePlus + - PixVerse + - Vidu + - Moonvalley + - LTX Studio + - Wan (Alibaba) +- Local asset vault - bypass ComfyUI's cloud storage +- API key management UI in ComfyUI settings +- Environment variable support for API keys +- JSON config file support +- Auto-detection of unmapped proxy endpoints +- Graceful fallback to ComfyUI proxy when keys not configured +- Frontend error interception with helpful key configuration prompts diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..6507d91 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2025 comfy-api-liberation contributors + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..733b29e --- /dev/null +++ b/README.md @@ -0,0 +1,115 @@ +# ComfyUI API Liberation + +**Use your own API keys. No ComfyUI account required.** + +ComfyUI's built-in API nodes route through `api.comfy.org` which requires an account and uses a credit system. This extension bypasses that entirely - your API calls go directly to the vendors (Google, OpenAI, Stability, etc.) using your own API keys. + +## Features + +- **Direct API routing** - Calls go straight to vendor APIs, not through ComfyUI's proxy +- **No account needed** - Works without a ComfyUI/Comfy.org account +- **Local asset storage** - Images/videos stored locally instead of uploaded to Comfy servers +- **20+ providers supported** - All major AI APIs covered +- **Simple UI** - Configure keys through ComfyUI's interface +- **Graceful fallback** - If no key is set, falls back to default ComfyUI behavior + +## Installation + +```bash +cd ComfyUI/custom_nodes +git clone https://github.com/oxysoft/comfy-api-liberation.git +``` + +Restart ComfyUI after installation. + +## Supported Providers + +| Provider | Get API Key | +|----------|-------------| +| Google (Gemini, Veo, Imagen) | [Google AI Studio](https://aistudio.google.com/app/apikey) | +| OpenAI (GPT, DALL-E, Sora) | [OpenAI Platform](https://platform.openai.com/api-keys) | +| Stability AI | [Stability Platform](https://platform.stability.ai/account/keys) | +| BFL (Flux) | [BFL API](https://api.bfl.ml) | +| Ideogram | [Ideogram API](https://ideogram.ai/manage-api) | +| Recraft | [Recraft Docs](https://www.recraft.ai/docs/api-reference/getting-started) | +| Luma Labs | [Luma API Keys](https://lumalabs.ai/dream-machine/api/keys) | +| Runway | [Runway Dev](https://dev.runwayml.com) | +| Kling AI | [Kling API](https://app.klingai.com/global/dev/api-key) | +| MiniMax | [MiniMax Platform](https://platform.minimax.io) | +| Pika | [Pika API](https://pika.art/api) | +| Tripo3D | [Tripo Platform](https://platform.tripo3d.ai) | +| Rodin (Hyper Human) | [Rodin Dashboard](https://hyperhuman.deemos.com/api-dashboard) | +| Topaz Labs | [Topaz API](https://www.topazlabs.com/api) | +| BytePlus | [BytePlus Console](https://console.byteplus.com) | +| PixVerse | [PixVerse Platform](https://platform.pixverse.ai) | +| Vidu | [Vidu Platform](https://platform.vidu.com) | +| Moonvalley | [Moonvalley](https://www.moonvalley.com) | +| LTX Studio | [LTX API](https://ltx.io/model/api) | +| Wan (Alibaba) | [Alibaba Model Studio](https://www.alibabacloud.com/help/en/model-studio/get-api-key) | + +## Configuration + +### Option 1: UI (Recommended) + +1. In ComfyUI, go to **Settings** (gear icon) +2. Find **API Liberation** section +3. Enter your API keys for each provider +4. Keys are saved locally to `api_keys.json` + +### Option 2: Environment Variables + +Set environment variables before starting ComfyUI: + +```bash +export GOOGLE_API_KEY="your-key-here" +export OPENAI_API_KEY="your-key-here" +export STABILITY_API_KEY="your-key-here" +# ... etc +``` + +### Option 3: Config File + +Create `api_keys.json` in the extension directory: + +```json +{ + "google": "your-google-key", + "openai": "your-openai-key", + "stability": "your-stability-key" +} +``` + +**Priority:** Environment variables > Config file > UI settings + +## How It Works + +ComfyUI's API nodes make requests to `/proxy/*` endpoints on `api.comfy.org`. This extension: + +1. **Intercepts** outgoing API requests before they leave ComfyUI +2. **Rewrites** `/proxy/vendor/...` URLs to direct vendor API URLs +3. **Injects** your API key as the appropriate auth header +4. **Virtualizes** the file upload system so assets stay local + +All of this happens transparently - existing workflows work without modification. + +## Debugging + +Enable debug logging: + +```bash +LIBERATION_DEBUG=1 python main.py +``` + +## Security Notes + +- API keys are stored locally in `api_keys.json` (gitignored by default) +- Keys are never sent to ComfyUI/Comfy.org servers +- Each provider only receives its own API key + +## License + +MIT License - see [LICENSE](LICENSE) file. + +## Contributing + +Issues and PRs welcome. Please ensure `api_keys.json` is never committed. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..eee2bc1 --- /dev/null +++ b/__init__.py @@ -0,0 +1,78 @@ +""" +ComfyUI API Liberation Extension + +Bypasses ComfyUI's credit proxy system by routing API calls directly to vendors +using your own API keys. + +Features: +- Direct routing to vendor APIs (Google, OpenAI, Stability, etc.) +- Key management via UI, config file, or environment variables +- Auto-detection of new/unmapped proxy endpoints +- Graceful fallback to Comfy proxy when keys not configured +""" +import logging +import os + +# Plugin logger (do not touch global logging config) +log = logging.getLogger("comfy-api-liberation") +log.setLevel(logging.DEBUG if os.environ.get("LIBERATION_DEBUG") == "1" else logging.INFO) + +# Version +__version__ = "1.0.0" + + +def _init_extension(): + """Initialize the extension: apply patches, register routes, check coverage.""" + + # 1. Apply monkey patches to intercept API calls + log.info("Initializing comfy-api-liberation...") + + try: + from .patches import apply_patches + apply_patches() + except Exception as e: + log.error(f"Failed to apply patches: {e}") + + # 2. Register API routes for key management + try: + from .routes import register_routes + register_routes() + except Exception as e: + log.warning(f"Failed to register routes (server may not be ready): {e}") + + # 3. Check coverage and warn about unmapped endpoints + try: + from .discovery import detect_unmapped_endpoints + from .mappings import ENDPOINT_MAP + + unmapped = detect_unmapped_endpoints(ENDPOINT_MAP) + if unmapped: + log.warning(f"Found {len(unmapped)} unmapped proxy endpoints (will use Comfy proxy):") + for ep in sorted(list(unmapped)[:5]): + log.warning(f" - {ep}") + if len(unmapped) > 5: + log.warning(f" ... and {len(unmapped) - 5} more") + else: + log.info(f"All {len(ENDPOINT_MAP)} proxy endpoints mapped!") + + except Exception as e: + log.debug(f"Coverage check failed: {e}") + + log.info(f"comfy-api-liberation v{__version__} loaded") + + +# Initialize on import +_init_extension() + + +# Export nodes for ComfyUI +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +# Register web extension directory +WEB_DIRECTORY = "./web" + +__all__ = [ + "NODE_CLASS_MAPPINGS", + "NODE_DISPLAY_NAME_MAPPINGS", + "WEB_DIRECTORY", +] diff --git a/asset_vault.py b/asset_vault.py new file mode 100644 index 0000000..f5ee02e --- /dev/null +++ b/asset_vault.py @@ -0,0 +1,121 @@ +import base64 +import os +import time +import uuid +from dataclasses import dataclass +from pathlib import Path +from threading import Lock +from typing import Optional +from urllib.parse import urlparse + + +LIBERATION_SCHEME = "liberation" +_UPLOAD_NETLOC = "upload" +_ASSET_NETLOC = "asset" + + +@dataclass(frozen=True) +class AssetRecord: + path: Path + filename: str | None + content_type: str | None + size_bytes: int + created_at: float + + +class AssetVault: + """ + Local asset vault for API nodes. + + It fakes Comfy's `/customers/storage` flow by issuing an "upload URL" that is + handled in-process (no network) and a "download URL" that is a custom URI + scheme (`liberation://asset/`). Provider-specific request transforms + can then materialize these URIs into vendor-compatible payloads. + """ + + def __init__(self, root_dir: Path): + self._root_dir = root_dir + self._root_dir.mkdir(parents=True, exist_ok=True) + self._lock = Lock() + self._records: dict[str, AssetRecord] = {} + + def create(self, filename: str | None, content_type: str | None) -> str: + asset_id = uuid.uuid4().hex + safe_name = filename or f"asset_{asset_id}" + # Avoid path traversal; keep only basename. + safe_name = os.path.basename(safe_name) + file_path = self._root_dir / f"{asset_id}__{safe_name}" + record = AssetRecord( + path=file_path, + filename=filename, + content_type=content_type, + size_bytes=0, + created_at=time.time(), + ) + with self._lock: + self._records[asset_id] = record + return asset_id + + def put(self, asset_id: str, data: bytes, *, content_type: str | None = None, filename: str | None = None) -> None: + with self._lock: + record = self._records.get(asset_id) + if record is None: + raise KeyError(f"Unknown asset_id: {asset_id}") + + record.path.parent.mkdir(parents=True, exist_ok=True) + record.path.write_bytes(data) + + new_record = AssetRecord( + path=record.path, + filename=filename or record.filename, + content_type=content_type or record.content_type, + size_bytes=len(data), + created_at=record.created_at, + ) + with self._lock: + self._records[asset_id] = new_record + + def get_bytes(self, asset_id: str) -> tuple[bytes, str | None, str | None]: + with self._lock: + record = self._records.get(asset_id) + if record is None: + raise KeyError(f"Unknown asset_id: {asset_id}") + return record.path.read_bytes(), record.content_type, record.filename + + def to_asset_uri(self, asset_id: str) -> str: + return f"{LIBERATION_SCHEME}://{_ASSET_NETLOC}/{asset_id}" + + def to_upload_uri(self, asset_id: str) -> str: + return f"{LIBERATION_SCHEME}://{_UPLOAD_NETLOC}/{asset_id}" + + def parse_asset_id(self, uri: str, *, netloc: str) -> Optional[str]: + try: + p = urlparse(uri) + except Exception: + return None + if p.scheme != LIBERATION_SCHEME or p.netloc != netloc: + return None + asset_id = (p.path or "").lstrip("/") + return asset_id or None + + def parse_asset_uri(self, uri: str) -> Optional[str]: + return self.parse_asset_id(uri, netloc=_ASSET_NETLOC) + + def parse_upload_uri(self, uri: str) -> Optional[str]: + return self.parse_asset_id(uri, netloc=_UPLOAD_NETLOC) + + def to_data_url(self, asset_id: str) -> str: + data, content_type, _filename = self.get_bytes(asset_id) + mime = content_type or "application/octet-stream" + b64 = base64.b64encode(data).decode("utf-8") + return f"data:{mime};base64,{b64}" + + +def _default_vault_dir() -> Path: + # .../ComfyUI/custom_nodes/comfy-api-liberation/asset_vault.py -> .../ComfyUI + comfy_root = Path(__file__).resolve().parents[2] + return comfy_root / "user" / "comfy-api-liberation-assets" + + +vault = AssetVault(_default_vault_dir()) + diff --git a/config.py b/config.py new file mode 100644 index 0000000..11810e0 --- /dev/null +++ b/config.py @@ -0,0 +1,109 @@ +""" +API key configuration management. +All keys stored in JSON config file, managed via UI. +No environment variables - clean, simple, user-controlled. +""" +import json +import logging +from pathlib import Path +from threading import Lock + +log = logging.getLogger("comfy-api-liberation") + +_CONFIG_PATH = Path(__file__).parent / "api_keys.json" +_keys_cache: dict[str, str] = {} +_cache_lock = Lock() +_loaded = False + +# All supported providers +PROVIDERS = [ + 'google', + 'openai', + 'stability', + 'bfl', + 'ideogram', + 'recraft', + 'luma', + 'runway', + 'kling', + 'minimax', + 'pika', + 'tripo', + 'rodin', + 'topaz', + 'byteplus', + 'pixverse', + 'vidu', + 'moonvalley', + 'ltx', + 'wan', +] + + +def _load_config(): + """Load config from JSON file if not already loaded.""" + global _loaded + with _cache_lock: + if _loaded: + return + if _CONFIG_PATH.exists(): + try: + data = json.loads(_CONFIG_PATH.read_text()) + _keys_cache.update(data) + log.info(f"Loaded {len(data)} API keys from config") + except Exception as e: + log.warning(f"Failed to load config: {e}") + _loaded = True + + +def _save_config(): + """Save current keys cache to JSON file.""" + with _cache_lock: + try: + _CONFIG_PATH.write_text(json.dumps(_keys_cache, indent=2)) + except Exception as e: + log.error(f"Failed to save config: {e}") + + +def get_api_key(provider: str) -> str | None: + """Get API key for a provider from config.""" + _load_config() + with _cache_lock: + return _keys_cache.get(provider) + + +def set_api_key(provider: str, key: str): + """Set API key for a provider (persists to config file).""" + _load_config() + with _cache_lock: + _keys_cache[provider] = key + _save_config() + log.info(f"API key set for {provider}") + + +def delete_api_key(provider: str): + """Delete API key for a provider.""" + _load_config() + with _cache_lock: + _keys_cache.pop(provider, None) + _save_config() + log.info(f"API key deleted for {provider}") + + +def get_all_key_status() -> dict[str, dict]: + """ + Get status of all providers (configured or not). + Returns dict like: {"google": {"configured": True, "source": "config-file"}, ...} + """ + _load_config() + result = {} + + for provider in PROVIDERS: + with _cache_lock: + has_key = bool(_keys_cache.get(provider)) + result[provider] = { + "configured": has_key, + "source": "config-file" if has_key else None, + } + + return result diff --git a/discovery.py b/discovery.py new file mode 100644 index 0000000..b4c607a --- /dev/null +++ b/discovery.py @@ -0,0 +1,95 @@ +""" +Auto-discovery of proxy endpoints in ComfyUI. +Scans comfy_api_nodes/*.py for /proxy/* patterns to detect unmapped endpoints. +""" +import re +import logging +from pathlib import Path + +log = logging.getLogger("comfy-api-liberation") + + +def scan_proxy_endpoints() -> set[str]: + """ + Scan comfy_api_nodes/*.py for /proxy/* string literals. + Returns set of all proxy paths found. + """ + proxy_paths = set() + + try: + import comfy_api_nodes + nodes_dir = Path(comfy_api_nodes.__file__).parent + + for py_file in nodes_dir.glob("nodes_*.py"): + try: + content = py_file.read_text() + # Extract all "/proxy/..." string literals + matches = re.findall(r'"/proxy/[^"]+', content) + for match in matches: + # Clean up the path (remove quotes, normalize) + path = match.strip('"') + # Normalize dynamic parts like {model} to wildcards for comparison + proxy_paths.add(path) + except Exception as e: + log.debug(f"Failed to scan {py_file}: {e}") + + except ImportError: + log.warning("comfy_api_nodes not found - cannot scan for proxy endpoints") + + return proxy_paths + + +def detect_unmapped_endpoints(endpoint_map: dict) -> set[str]: + """ + Find proxy endpoints that don't have mappings in our registry. + + Args: + endpoint_map: Dict of regex pattern → mapping info + + Returns: + Set of proxy paths that aren't covered by any mapping pattern + """ + all_proxies = scan_proxy_endpoints() + patterns = list(endpoint_map.keys()) + + unmapped = set() + for proxy_path in all_proxies: + # Check if any pattern matches this proxy path + matched = False + for pattern in patterns: + # Handle paths with template variables like {model} + # Normalize {var} to .+ for matching + normalized_path = re.sub(r'\{[^}]+\}', '[^/]+', proxy_path) + try: + if re.match(pattern, proxy_path) or re.match(pattern, normalized_path): + matched = True + break + except re.error: + continue + + if not matched: + unmapped.add(proxy_path) + + return unmapped + + +def get_proxy_coverage_report(endpoint_map: dict) -> str: + """Generate a human-readable coverage report.""" + all_proxies = scan_proxy_endpoints() + unmapped = detect_unmapped_endpoints(endpoint_map) + mapped = all_proxies - unmapped + + lines = [ + "=== API Liberation Coverage Report ===", + f"Total proxy endpoints found: {len(all_proxies)}", + f"Mapped (direct routing): {len(mapped)}", + f"Unmapped (using Comfy proxy): {len(unmapped)}", + "", + ] + + if unmapped: + lines.append("Unmapped endpoints:") + for ep in sorted(unmapped): + lines.append(f" - {ep}") + + return "\n".join(lines) diff --git a/mappings/__init__.py b/mappings/__init__.py new file mode 100644 index 0000000..00ce11c --- /dev/null +++ b/mappings/__init__.py @@ -0,0 +1,77 @@ +""" +Endpoint mapping registry. +Maps ComfyUI proxy paths to direct vendor APIs. +""" +import logging +from typing import Callable, TypedDict + +log = logging.getLogger("comfy-api-liberation") + + +class ProviderMapping(TypedDict, total=False): + provider: str # Key name in config (e.g., "google") + url_template: str # Vendor URL with {0}, {1} placeholders + auth_fn: Callable[[str], dict[str, str]] # Returns auth headers given API key + request_transform: Callable | None # Optional request body transform + response_transform: Callable | None # Optional response body transform + + +# Global registry: regex pattern → mapping +ENDPOINT_MAP: dict[str, ProviderMapping] = {} + + +def register(pattern: str, mapping: ProviderMapping): + """Register a new endpoint mapping.""" + ENDPOINT_MAP[pattern] = mapping + log.debug(f"Registered mapping: {pattern} → {mapping['provider']}") + + +def register_provider_module(module): + """Auto-register all mappings from a provider module that has MAPPINGS dict.""" + if hasattr(module, 'MAPPINGS'): + for pattern, mapping in module.MAPPINGS.items(): + register(pattern, mapping) + + +def _load_all_providers(): + """Load all provider mapping modules.""" + from . import ( + google, + openai, + stability, + bfl, + ideogram, + recraft, + luma, + runway, + kling, + minimax, + pika, + tripo, + rodin, + topaz, + byteplus, + pixverse, + vidu, + moonvalley, + ltx, + wan, + ) + + providers = [ + google, openai, stability, bfl, ideogram, recraft, + luma, runway, kling, minimax, pika, tripo, rodin, + topaz, byteplus, pixverse, vidu, moonvalley, ltx, wan, + ] + + for provider in providers: + try: + register_provider_module(provider) + except Exception as e: + log.warning(f"Failed to load provider {provider.__name__}: {e}") + + log.info(f"Loaded {len(ENDPOINT_MAP)} endpoint mappings") + + +# Load on import +_load_all_providers() diff --git a/mappings/bfl.py b/mappings/bfl.py new file mode 100644 index 0000000..289ec12 --- /dev/null +++ b/mappings/bfl.py @@ -0,0 +1,46 @@ +"""BFL (Black Forest Labs - Flux) endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/bfl/flux-2-pro/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-2-pro', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/bfl/flux-kontext-max/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-kontext-max', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/bfl/flux-kontext-pro/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-kontext-pro', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/bfl/flux-pro-1.0-expand/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-pro-1.0-expand', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/bfl/flux-pro-1.0-fill/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-pro-1.0-fill', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/bfl/flux-pro-1.1-ultra/generate$': { + 'provider': 'bfl', + 'url_template': 'https://api.bfl.ml/v1/flux-pro-1.1-ultra', + 'auth_fn': lambda key: {'x-key': key}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/byteplus.py b/mappings/byteplus.py new file mode 100644 index 0000000..1eb9978 --- /dev/null +++ b/mappings/byteplus.py @@ -0,0 +1,25 @@ +"""BytePlus (ByteDance) endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/byteplus/api/v3/images/generations$': { + 'provider': 'byteplus', + 'url_template': 'https://api.byteplus.com/api/v3/images/generations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/byteplus/api/v3/contents/generations/tasks$': { + 'provider': 'byteplus', + 'url_template': 'https://api.byteplus.com/api/v3/contents/generations/tasks', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/byteplus/api/v3/contents/generations/tasks/([^/]+)$': { + 'provider': 'byteplus', + 'url_template': 'https://api.byteplus.com/api/v3/contents/generations/tasks/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/google.py b/mappings/google.py new file mode 100644 index 0000000..8a8682d --- /dev/null +++ b/mappings/google.py @@ -0,0 +1,152 @@ +"""Google (Gemini, Veo) endpoint mappings.""" +import base64 +import logging + +from ..asset_vault import vault + +log = logging.getLogger("comfy-api-liberation") + + +def _strip_upload_images_flag(payload): + """Remove uploadImagesToStorage key Google doesn't accept.""" + if isinstance(payload, dict) and "uploadImagesToStorage" in payload: + return {k: v for k, v in payload.items() if k != "uploadImagesToStorage"} + return payload + + +def _materialize_gemini_filedatas(payload): + """ + Convert `fileData.fileUri` placeholders into `inlineData` for direct Google calls. + + Comfy's proxy can accept arbitrary file URLs (and may upload them to Google). When routing + directly to Google, the request must include bytes inline (base64) unless the URI points to + a Google-hosted location (e.g., `gs://...`). The liberation asset vault returns + `liberation://asset/` URIs which we must materialize here. + """ + if not isinstance(payload, dict): + return payload + + contents = payload.get("contents") + if not isinstance(contents, list): + return payload + + for content in contents: + if not isinstance(content, dict): + continue + parts = content.get("parts") + if not isinstance(parts, list): + continue + for part in parts: + if not isinstance(part, dict): + continue + file_data = part.get("fileData") + if not isinstance(file_data, dict): + continue + + file_uri = file_data.get("fileUri") + if not isinstance(file_uri, str): + continue + + asset_id = vault.parse_asset_uri(file_uri) + if not asset_id: + continue + + data, stored_content_type, _filename = vault.get_bytes(asset_id) + mime = file_data.get("mimeType") or stored_content_type or "application/octet-stream" + + part.pop("fileData", None) + part["inlineData"] = { + "mimeType": mime, + "data": base64.b64encode(data).decode("utf-8"), + } + + return payload + + +def _google_request_transform(payload): + payload = _strip_upload_images_flag(payload) + payload = _materialize_gemini_filedatas(payload) + return payload + + +def _fix_response_for_comfy(response_data): + """ + Fix response from Google's direct API to work with ComfyUI: + 1. Fix MIME types - Google returns image/jpeg but ComfyUI expects image/png + 2. Ensure usageMetadata has required fields to prevent price extractor errors + 3. Ensure content.role exists to prevent Pydantic validation errors + """ + if not isinstance(response_data, dict): + return response_data + + try: + candidates = response_data.get("candidates", []) + if candidates: + for candidate in candidates: + content = candidate.get("content") + + # Handle empty/missing content (rate limit, content filter, etc.) + if content is None or content == {}: + # Provide minimal valid structure + candidate["content"] = { + "role": "model", + "parts": [] + } + content = candidate["content"] + + # Ensure role field exists (required by Pydantic) + if "role" not in content: + content["role"] = "model" + + # Fix MIME types in image parts + parts = content.get("parts", []) + for part in parts: + if "inlineData" in part: + mime = part["inlineData"].get("mimeType", "") + if mime.startswith("image/") and mime != "image/png": + part["inlineData"]["mimeType"] = "image/png" + elif "fileData" in part: + mime = part["fileData"].get("mimeType", "") + if mime.startswith("image/") and mime != "image/png": + part["fileData"]["mimeType"] = "image/png" + + # Ensure usageMetadata exists with required fields to prevent price extractor crash + if "usageMetadata" not in response_data: + response_data["usageMetadata"] = {} + usage = response_data["usageMetadata"] + if usage.get("promptTokenCount") is None: + usage["promptTokenCount"] = 0 + if usage.get("candidatesTokenCount") is None: + usage["candidatesTokenCount"] = 0 + + except Exception as e: + log.debug(f"[liberation] response transform error: {e}") + + return response_data + + +MAPPINGS = { + # Gemini text/multimodal + r'^/proxy/vertexai/gemini/(.+)$': { + 'provider': 'google', + 'url_template': 'https://generativelanguage.googleapis.com/v1beta/models/{0}:generateContent', + 'auth_fn': lambda key: {'x-goog-api-key': key}, + 'request_transform': _google_request_transform, + 'response_transform': _fix_response_for_comfy, + }, + # Veo video generation + r'^/proxy/veo/([^/]+)/generate$': { + 'provider': 'google', + 'url_template': 'https://generativelanguage.googleapis.com/v1beta/models/{0}:generateContent', + 'auth_fn': lambda key: {'x-goog-api-key': key}, + 'request_transform': _google_request_transform, + 'response_transform': None, + }, + r'^/proxy/veo/([^/]+)/poll$': { + 'provider': 'google', + 'url_template': 'https://generativelanguage.googleapis.com/v1beta/models/{0}:generateContent', + 'auth_fn': lambda key: {'x-goog-api-key': key}, + 'request_transform': _google_request_transform, + 'response_transform': None, + }, +} diff --git a/mappings/ideogram.py b/mappings/ideogram.py new file mode 100644 index 0000000..40f9fd5 --- /dev/null +++ b/mappings/ideogram.py @@ -0,0 +1,25 @@ +"""Ideogram endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/ideogram/generate$': { + 'provider': 'ideogram', + 'url_template': 'https://api.ideogram.ai/generate', + 'auth_fn': lambda key: {'Api-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/ideogram/ideogram-v3/generate$': { + 'provider': 'ideogram', + 'url_template': 'https://api.ideogram.ai/v3/generate', + 'auth_fn': lambda key: {'Api-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/ideogram/ideogram-v3/edit$': { + 'provider': 'ideogram', + 'url_template': 'https://api.ideogram.ai/v3/edit', + 'auth_fn': lambda key: {'Api-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/kling.py b/mappings/kling.py new file mode 100644 index 0000000..2bd902b --- /dev/null +++ b/mappings/kling.py @@ -0,0 +1,86 @@ +"""Kling AI endpoint mappings.""" + +# Note: Kling uses versioned endpoints like {KLING_API_VERSION} +# We'll match the common patterns + +MAPPINGS = { + # Image generation + r'^/proxy/kling/v\d+/images/generations$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/images/generations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v\d+/images/kolors-virtual-try-on$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/images/kolors-virtual-try-on', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v1/images/omni-image$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/images/omni-image', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v1/images/omni-image/([^/]+)$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/images/omni-image/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Video generation + r'^/proxy/kling/v\d+/videos/text2video$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/text2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v\d+/videos/image2video$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/image2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v\d+/videos/video-extend$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/video-extend', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v\d+/videos/effects$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/effects', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v\d+/videos/lip-sync$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/lip-sync', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v1/videos/omni-video$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/omni-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/kling/v1/videos/omni-video/([^/]+)$': { + 'provider': 'kling', + 'url_template': 'https://api.klingai.com/v1/videos/omni-video/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/ltx.py b/mappings/ltx.py new file mode 100644 index 0000000..957fa62 --- /dev/null +++ b/mappings/ltx.py @@ -0,0 +1,18 @@ +"""LTX Studio endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/ltx/v1/text-to-video$': { + 'provider': 'ltx', + 'url_template': 'https://api.ltx.studio/v1/text-to-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/ltx/v1/image-to-video$': { + 'provider': 'ltx', + 'url_template': 'https://api.ltx.studio/v1/image-to-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/luma.py b/mappings/luma.py new file mode 100644 index 0000000..b9ee5f1 --- /dev/null +++ b/mappings/luma.py @@ -0,0 +1,25 @@ +"""Luma AI endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/luma/generations$': { + 'provider': 'luma', + 'url_template': 'https://api.lumalabs.ai/dream-machine/v1/generations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/luma/generations/image$': { + 'provider': 'luma', + 'url_template': 'https://api.lumalabs.ai/dream-machine/v1/generations/image', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/luma/generations/([^/]+)$': { + 'provider': 'luma', + 'url_template': 'https://api.lumalabs.ai/dream-machine/v1/generations/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/minimax.py b/mappings/minimax.py new file mode 100644 index 0000000..a0e8256 --- /dev/null +++ b/mappings/minimax.py @@ -0,0 +1,25 @@ +"""MiniMax endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/minimax/video_generation$': { + 'provider': 'minimax', + 'url_template': 'https://api.minimax.chat/v1/video_generation', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/minimax/query/video_generation$': { + 'provider': 'minimax', + 'url_template': 'https://api.minimax.chat/v1/query/video_generation', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/minimax/files/retrieve$': { + 'provider': 'minimax', + 'url_template': 'https://api.minimax.chat/v1/files/retrieve', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/moonvalley.py b/mappings/moonvalley.py new file mode 100644 index 0000000..e870ddf --- /dev/null +++ b/mappings/moonvalley.py @@ -0,0 +1,46 @@ +"""Moonvalley endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/moonvalley/uploads$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/uploads', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/moonvalley/prompts$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/prompts', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/moonvalley/prompts/([^/]+)$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/prompts/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/moonvalley/prompts/text-to-video$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/prompts/text-to-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/moonvalley/prompts/image-to-video$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/prompts/image-to-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/moonvalley/prompts/video-to-video$': { + 'provider': 'moonvalley', + 'url_template': 'https://api.moonvalley.ai/v1/prompts/video-to-video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/openai.py b/mappings/openai.py new file mode 100644 index 0000000..5b0ecff --- /dev/null +++ b/mappings/openai.py @@ -0,0 +1,56 @@ +"""OpenAI (GPT, DALL-E, Sora) endpoint mappings.""" + +MAPPINGS = { + # Responses API (GPT, etc) + r'^/proxy/openai/v1/responses$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/responses', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/openai/v1/responses/(.+)$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/responses/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Image generation (DALL-E) + r'^/proxy/openai/images/generations$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/images/generations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/openai/images/edits$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/images/edits', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Sora video + r'^/proxy/openai/v1/videos$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/videos', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/openai/v1/videos/([^/]+)$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/videos/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/openai/v1/videos/([^/]+)/content$': { + 'provider': 'openai', + 'url_template': 'https://api.openai.com/v1/videos/{0}/content', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/pika.py b/mappings/pika.py new file mode 100644 index 0000000..8f40f23 --- /dev/null +++ b/mappings/pika.py @@ -0,0 +1,69 @@ +"""Pika endpoint mappings.""" + +# Pika uses versioned endpoints like {PIKA_API_VERSION} + +MAPPINGS = { + r'^/proxy/pika/generate/v\d+/t2v$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/t2v', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/v\d+/i2v$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/i2v', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/v\d+/pikaframes$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/pikaframes', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/v\d+/pikascenes$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/pikascenes', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/pikadditions$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/pikadditions', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/pikaffects$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/pikaffects', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/generate/pikaswaps$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/generate/pikaswaps', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/videos$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/videos', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pika/videos/([^/]+)$': { + 'provider': 'pika', + 'url_template': 'https://api.pika.art/v1/videos/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/pixverse.py b/mappings/pixverse.py new file mode 100644 index 0000000..ab4c948 --- /dev/null +++ b/mappings/pixverse.py @@ -0,0 +1,39 @@ +"""PixVerse endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/pixverse/image/upload$': { + 'provider': 'pixverse', + 'url_template': 'https://api.pixverse.ai/v1/image/upload', + 'auth_fn': lambda key: {'API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pixverse/video/img/generate$': { + 'provider': 'pixverse', + 'url_template': 'https://api.pixverse.ai/v1/video/img/generate', + 'auth_fn': lambda key: {'API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pixverse/video/text/generate$': { + 'provider': 'pixverse', + 'url_template': 'https://api.pixverse.ai/v1/video/text/generate', + 'auth_fn': lambda key: {'API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pixverse/video/transition/generate$': { + 'provider': 'pixverse', + 'url_template': 'https://api.pixverse.ai/v1/video/transition/generate', + 'auth_fn': lambda key: {'API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/pixverse/video/result/([^/]+)$': { + 'provider': 'pixverse', + 'url_template': 'https://api.pixverse.ai/v1/video/result/{0}', + 'auth_fn': lambda key: {'API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/recraft.py b/mappings/recraft.py new file mode 100644 index 0000000..2663703 --- /dev/null +++ b/mappings/recraft.py @@ -0,0 +1,60 @@ +"""Recraft endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/recraft/image_generation$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/generations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/imageToImage$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/imageToImage', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/inpaint$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/inpaint', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/removeBackground$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/removeBackground', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/replaceBackground$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/replaceBackground', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/vectorize$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/vectorize', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/creativeUpscale$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/creativeUpscale', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/recraft/images/crispUpscale$': { + 'provider': 'recraft', + 'url_template': 'https://external.api.recraft.ai/v1/images/crispUpscale', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/rodin.py b/mappings/rodin.py new file mode 100644 index 0000000..765d843 --- /dev/null +++ b/mappings/rodin.py @@ -0,0 +1,25 @@ +"""Rodin (Deemos/Hyperhuman) endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/rodin/api/v2/rodin$': { + 'provider': 'rodin', + 'url_template': 'https://hyperhuman.deemos.com/api/v2/rodin', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/rodin/api/v2/status$': { + 'provider': 'rodin', + 'url_template': 'https://hyperhuman.deemos.com/api/v2/status', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/rodin/api/v2/download$': { + 'provider': 'rodin', + 'url_template': 'https://hyperhuman.deemos.com/api/v2/download', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/runway.py b/mappings/runway.py new file mode 100644 index 0000000..730346d --- /dev/null +++ b/mappings/runway.py @@ -0,0 +1,32 @@ +"""Runway endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/runway/image_to_video$': { + 'provider': 'runway', + 'url_template': 'https://api.dev.runwayml.com/v1/image_to_video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/runway/text_to_image$': { + 'provider': 'runway', + 'url_template': 'https://api.dev.runwayml.com/v1/text_to_image', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/runway/tasks$': { + 'provider': 'runway', + 'url_template': 'https://api.dev.runwayml.com/v1/tasks', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/runway/tasks/([^/]+)$': { + 'provider': 'runway', + 'url_template': 'https://api.dev.runwayml.com/v1/tasks/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/stability.py b/mappings/stability.py new file mode 100644 index 0000000..242061a --- /dev/null +++ b/mappings/stability.py @@ -0,0 +1,72 @@ +"""Stability AI endpoint mappings.""" + +MAPPINGS = { + # SD3 image generation + r'^/proxy/stability/v2beta/stable-image/generate/sd3$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/stable-image/generate/sd3', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Ultra image generation + r'^/proxy/stability/v2beta/stable-image/generate/ultra$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/stable-image/generate/ultra', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Upscale endpoints + r'^/proxy/stability/v2beta/stable-image/upscale/conservative$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/stable-image/upscale/conservative', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/stability/v2beta/stable-image/upscale/creative$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/stable-image/upscale/creative', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/stability/v2beta/stable-image/upscale/fast$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/stable-image/upscale/fast', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Audio endpoints + r'^/proxy/stability/v2beta/audio/stable-audio-2/text-to-audio$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/audio/stable-audio-2/text-to-audio', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/stability/v2beta/audio/stable-audio-2/audio-to-audio$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/audio/stable-audio-2/audio-to-audio', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/stability/v2beta/audio/stable-audio-2/inpaint$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/audio/stable-audio-2/inpaint', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + # Results polling + r'^/proxy/stability/v2beta/results/(.+)$': { + 'provider': 'stability', + 'url_template': 'https://api.stability.ai/v2beta/results/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/topaz.py b/mappings/topaz.py new file mode 100644 index 0000000..018f6e3 --- /dev/null +++ b/mappings/topaz.py @@ -0,0 +1,54 @@ +"""Topaz Labs endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/topaz/image/v1/enhance-gen/async$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/image/v1/enhance-gen/async', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/topaz/image/v1/status/([^/]+)$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/image/v1/status/{0}', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/topaz/image/v1/download/([^/]+)$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/image/v1/download/{0}', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + # Video endpoints + r'^/proxy/topaz/video/$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/video/', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/topaz/video/([^/]+)/accept$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/video/{0}/accept', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/topaz/video/([^/]+)/complete-upload$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/video/{0}/complete-upload', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/topaz/video/([^/]+)/status$': { + 'provider': 'topaz', + 'url_template': 'https://api.topazlabs.com/video/{0}/status', + 'auth_fn': lambda key: {'X-API-Key': key}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/tripo.py b/mappings/tripo.py new file mode 100644 index 0000000..05aae80 --- /dev/null +++ b/mappings/tripo.py @@ -0,0 +1,18 @@ +"""Tripo3D endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/tripo/v2/openapi/task$': { + 'provider': 'tripo', + 'url_template': 'https://api.tripo3d.ai/v2/openapi/task', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/tripo/v2/openapi/task/([^/]+)$': { + 'provider': 'tripo', + 'url_template': 'https://api.tripo3d.ai/v2/openapi/task/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/vidu.py b/mappings/vidu.py new file mode 100644 index 0000000..0c1404e --- /dev/null +++ b/mappings/vidu.py @@ -0,0 +1,39 @@ +"""Vidu endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/vidu/text2video$': { + 'provider': 'vidu', + 'url_template': 'https://api.vidu.studio/v1/text2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/vidu/img2video$': { + 'provider': 'vidu', + 'url_template': 'https://api.vidu.studio/v1/img2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/vidu/reference2video$': { + 'provider': 'vidu', + 'url_template': 'https://api.vidu.studio/v1/reference2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/vidu/start-end2video$': { + 'provider': 'vidu', + 'url_template': 'https://api.vidu.studio/v1/start-end2video', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/vidu/tasks/([^/]+)/creations$': { + 'provider': 'vidu', + 'url_template': 'https://api.vidu.studio/v1/tasks/{0}/creations', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/mappings/wan.py b/mappings/wan.py new file mode 100644 index 0000000..dcafb26 --- /dev/null +++ b/mappings/wan.py @@ -0,0 +1,32 @@ +"""Wan (Alibaba DashScope) endpoint mappings.""" + +MAPPINGS = { + r'^/proxy/wan/api/v1/services/aigc/text2image/image-synthesis$': { + 'provider': 'wan', + 'url_template': 'https://dashscope.aliyuncs.com/api/v1/services/aigc/text2image/image-synthesis', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/wan/api/v1/services/aigc/image2image/image-synthesis$': { + 'provider': 'wan', + 'url_template': 'https://dashscope.aliyuncs.com/api/v1/services/aigc/image2image/image-synthesis', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/wan/api/v1/services/aigc/video-generation/video-synthesis$': { + 'provider': 'wan', + 'url_template': 'https://dashscope.aliyuncs.com/api/v1/services/aigc/video-generation/video-synthesis', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, + r'^/proxy/wan/api/v1/tasks/([^/]+)$': { + 'provider': 'wan', + 'url_template': 'https://dashscope.aliyuncs.com/api/v1/tasks/{0}', + 'auth_fn': lambda key: {'Authorization': f'Bearer {key}'}, + 'request_transform': None, + 'response_transform': None, + }, +} diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..41130ab --- /dev/null +++ b/nodes.py @@ -0,0 +1,148 @@ +""" +ComfyUI nodes for API key management. +""" + + +class APIKeyManager: + """ + Node to manage API keys within ComfyUI. + Keys are stored securely in config, NOT in the workflow file. + """ + + @classmethod + def INPUT_TYPES(cls): + from .config import PROVIDERS + providers = sorted(PROVIDERS) + + return { + "required": { + "provider": (providers, {"default": "google"}), + "action": (["check", "set", "clear"], {"default": "check"}), + }, + "optional": { + "api_key": ("STRING", { + "default": "", + "multiline": False, + "placeholder": "Enter API key (not saved in workflow)" + }), + } + } + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + FUNCTION = "manage_key" + CATEGORY = "api-liberation" + OUTPUT_NODE = True + + def manage_key(self, provider: str, action: str, api_key: str = ""): + from .config import set_api_key, get_api_key, delete_api_key + + if action == "set": + if not api_key: + return ("⚠ No key provided",) + set_api_key(provider, api_key) + return (f"✓ {provider} key saved",) + + elif action == "check": + has_key = bool(get_api_key(provider)) + status = "✓ configured" if has_key else "✗ not set" + return (f"{provider}: {status}",) + + elif action == "clear": + delete_api_key(provider) + return (f"✓ {provider} key cleared",) + + return ("Unknown action",) + + +class APIKeyStatus: + """ + Node to check status of all API keys at once. + """ + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status_report",) + FUNCTION = "get_status" + CATEGORY = "api-liberation" + + def get_status(self): + from .config import get_all_key_status + + status = get_all_key_status() + lines = ["=== API Key Status ==="] + + configured = [] + missing = [] + + for provider, info in sorted(status.items()): + if info["configured"]: + source = info.get("source") or "config-file" + configured.append(f" ✓ {provider} ({source})") + else: + missing.append(f" ✗ {provider}") + + if configured: + lines.append("\nConfigured:") + lines.extend(configured) + + if missing: + lines.append("\nNot configured:") + lines.extend(missing) + + return ("\n".join(lines),) + + +class LiberationStatus: + """ + Node to check liberation extension status. + """ + + @classmethod + def INPUT_TYPES(cls): + return {"required": {}} + + RETURN_TYPES = ("STRING",) + RETURN_NAMES = ("status",) + FUNCTION = "get_status" + CATEGORY = "api-liberation" + + def get_status(self): + from .patches import is_patched + from .discovery import detect_unmapped_endpoints + from .mappings import ENDPOINT_MAP + + unmapped = detect_unmapped_endpoints(ENDPOINT_MAP) + + lines = [ + "=== Liberation Status ===", + f"Patches: {'✓ Active' if is_patched() else '✗ Inactive'}", + f"Mapped providers: {len(ENDPOINT_MAP)}", + f"Unmapped endpoints: {len(unmapped)}", + ] + + if unmapped: + lines.append("\nUnmapped (using Comfy proxy):") + for ep in sorted(list(unmapped)[:10]): # Show first 10 + lines.append(f" - {ep}") + if len(unmapped) > 10: + lines.append(f" ... and {len(unmapped) - 10} more") + + return ("\n".join(lines),) + + +# Node registration +NODE_CLASS_MAPPINGS = { + "APIKeyManager": APIKeyManager, + "APIKeyStatus": APIKeyStatus, + "LiberationStatus": LiberationStatus, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "APIKeyManager": "🔑 API Key Manager", + "APIKeyStatus": "📋 API Key Status", + "LiberationStatus": "🔓 Liberation Status", +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..53049f1 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,29 @@ +{ + "name": "comfy-api-liberation", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "comfy-api-liberation", + "version": "1.0.0", + "devDependencies": { + "typescript": "^5.7.0" + } + }, + "node_modules/typescript": { + "version": "5.9.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.3.tgz", + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..498fb51 --- /dev/null +++ b/package.json @@ -0,0 +1,13 @@ +{ + "name": "comfy-api-liberation", + "version": "1.0.0", + "private": true, + "type": "module", + "scripts": { + "build": "tsc", + "watch": "tsc --watch" + }, + "devDependencies": { + "typescript": "^5.7.0" + } +} diff --git a/patches.py b/patches.py new file mode 100644 index 0000000..1ba5698 --- /dev/null +++ b/patches.py @@ -0,0 +1,217 @@ +""" +Monkey patches for ComfyUI's API client. + +Goals: +1) Rewrite `/proxy/*` API-node requests to direct vendor URLs with vendor auth. +2) Eliminate the Comfy account dependency for asset uploads by virtualizing + `/customers/storage` and capturing uploads into a local asset vault. +""" + +from __future__ import annotations + +import logging +from functools import wraps +from typing import Any, Optional +from urllib.parse import urlparse + +log = logging.getLogger("comfy-api-liberation") + +_patched = False +_original_request_base = None +_original_validate_or_raise = None +_original_upload_file = None + + +_NODE_MODULE_TO_PROVIDER: dict[str, str] = { + "comfy_api_nodes.nodes_gemini": "google", + "comfy_api_nodes.nodes_veo2": "google", + "comfy_api_nodes.nodes_openai": "openai", + "comfy_api_nodes.nodes_sora": "openai", + "comfy_api_nodes.nodes_stability": "stability", + "comfy_api_nodes.nodes_bfl": "bfl", + "comfy_api_nodes.nodes_ideogram": "ideogram", + "comfy_api_nodes.nodes_recraft": "recraft", + "comfy_api_nodes.nodes_luma": "luma", + "comfy_api_nodes.nodes_runway": "runway", + "comfy_api_nodes.nodes_kling": "kling", + "comfy_api_nodes.nodes_minimax": "minimax", + "comfy_api_nodes.nodes_tripo": "tripo", + "comfy_api_nodes.nodes_rodin": "rodin", + "comfy_api_nodes.nodes_topaz": "topaz", + # Comfy file is named `nodes_bytedance.py`, but provider config is `byteplus`. + "comfy_api_nodes.nodes_bytedance": "byteplus", + "comfy_api_nodes.nodes_pixverse": "pixverse", + "comfy_api_nodes.nodes_vidu": "vidu", + "comfy_api_nodes.nodes_moonvalley": "moonvalley", + # Comfy file is named `nodes_ltxv.py`, but provider config is `ltx`. + "comfy_api_nodes.nodes_ltxv": "ltx", + "comfy_api_nodes.nodes_wan": "wan", +} + + +def _infer_provider(node_cls: Any) -> Optional[str]: + mod = getattr(node_cls, "__module__", "") or "" + return _NODE_MODULE_TO_PROVIDER.get(mod) + + +def is_patched() -> bool: + return _patched + + +def apply_patches() -> None: + """ + Apply monkey patches. + Safe to call multiple times (only patches once). + """ + global _patched, _original_request_base, _original_validate_or_raise, _original_upload_file + + if _patched: + return + + import comfy_api_nodes.util.client as client_module + import comfy_api_nodes.util.upload_helpers as upload_module + + from .asset_vault import vault + from .config import get_api_key + from .router import get_current_provider, get_pending_response_transform, rewrite_request + + _original_request_base = client_module._request_base + _original_validate_or_raise = client_module._validate_or_raise + _original_upload_file = upload_module.upload_file + + async def patched_upload_file( + cls, + upload_url: str, + file, + *, + content_type: str | None = None, + max_retries: int = 3, + retry_delay: float = 1.0, + retry_backoff: float = 2.0, + wait_label: str | None = None, + progress_origin_ts: float | None = None, + ) -> None: + asset_id = vault.parse_upload_uri(upload_url) + if not asset_id: + return await _original_upload_file( + cls, + upload_url, + file, + content_type=content_type, + max_retries=max_retries, + retry_delay=retry_delay, + retry_backoff=retry_backoff, + wait_label=wait_label, + progress_origin_ts=progress_origin_ts, + ) + + # Store bytes locally; no network. + from io import BytesIO + + if isinstance(file, BytesIO): + try: + file.seek(0) + except Exception: + pass + data = file.read() + filename = getattr(file, "name", None) + elif isinstance(file, str): + with open(file, "rb") as f: + data = f.read() + filename = file + else: + raise ValueError("file must be a BytesIO or a filesystem path string") + + vault.put(asset_id, data, content_type=content_type, filename=filename) + log.debug("[liberation] Stored asset %s (%d bytes)", asset_id, len(data)) + + def patched_validate_or_raise(response_model, payload): + response_transform = get_pending_response_transform() + if response_transform and isinstance(payload, dict): + try: + payload = response_transform(payload) + except Exception as e: + log.debug("[liberation] Pre-validation transform error: %s", e) + return _original_validate_or_raise(response_model, payload) + + @wraps(_original_request_base) + async def patched_request_base(cfg, expect_binary): + # Intercept Comfy storage allocation so nodes can "upload" without a Comfy account. + if cfg.endpoint.path == "/customers/storage" and cfg.endpoint.method.upper() == "POST": + provider = _infer_provider(cfg.node_cls) + if provider and not get_api_key(provider): + raise Exception( + f"LIBERATION_MISSING_KEY:{provider}:API key required for '{provider}'. " + f"Configure your {provider.upper()} API key to use this node directly." + ) + + req = cfg.data or {} + filename = req.get("file_name") if isinstance(req, dict) else None + content_type = req.get("content_type") if isinstance(req, dict) else None + + asset_id = vault.create(filename=filename, content_type=content_type) + log.debug( + "[liberation] Vault allocated %s (provider=%s, filename=%s, content_type=%s)", + asset_id, + provider or "unknown", + filename, + content_type, + ) + return { + "download_url": vault.to_asset_uri(asset_id), + "upload_url": vault.to_upload_uri(asset_id), + } + + # Normal path: route /proxy/* requests to vendors. + original_path = cfg.endpoint.path + cfg = rewrite_request(cfg) + if cfg.endpoint.path != original_path: + log.debug("[liberation] Rewrote: %s → %s", original_path, cfg.endpoint.path) + + try: + return await _original_request_base(cfg, expect_binary) + except Exception as e: + msg = str(e) + + # If we routed to a vendor and got a 401, Comfy's client turns it into a misleading + # "Please login" message. Re-wrap it so the frontend can prompt for a new key. + parsed = urlparse(cfg.endpoint.path or "") + is_absolute = bool(parsed.scheme and parsed.netloc) + is_comfy_api = parsed.netloc.endswith("comfy.org") if parsed.netloc else False + if is_absolute and not is_comfy_api and msg == "Unauthorized: Please login first to use this node.": + provider = get_current_provider() or _infer_provider(cfg.node_cls) or "unknown" + log.warning("[liberation] Vendor 401 mapped to INVALID_KEY (provider=%s, url=%s)", provider, cfg.endpoint.path) + raise Exception( + f"LIBERATION_INVALID_KEY:{provider}:Vendor rejected your API key (HTTP 401). " + f"Update your {provider.upper()} API key and retry." + ) from None + + raise + + # Apply patches + upload_module.upload_file = patched_upload_file + client_module._validate_or_raise = patched_validate_or_raise + client_module._request_base = patched_request_base + + _patched = True + log.info("[comfy-api-liberation] Patches applied - direct API routing + asset vault enabled") + + +def restore_patches() -> None: + global _patched, _original_request_base, _original_validate_or_raise, _original_upload_file + if not _patched: + return + + try: + import comfy_api_nodes.util.client as client_module + import comfy_api_nodes.util.upload_helpers as upload_module + + if _original_request_base is not None: + client_module._request_base = _original_request_base + if _original_validate_or_raise is not None: + client_module._validate_or_raise = _original_validate_or_raise + if _original_upload_file is not None: + upload_module.upload_file = _original_upload_file + finally: + _patched = False + log.info("[comfy-api-liberation] Patches restored to original") diff --git a/router.py b/router.py new file mode 100644 index 0000000..2f54b01 --- /dev/null +++ b/router.py @@ -0,0 +1,161 @@ +""" +Request router - rewrites /proxy/* paths to direct vendor URLs. +""" +from contextvars import ContextVar +import re +import logging +from dataclasses import replace +from typing import Any, Callable + +# Import at module level to avoid hot-reload issues with relative imports +from . import mappings +from . import config +from .asset_vault import vault +from comfy_api_nodes.util.client import ApiEndpoint + +log = logging.getLogger("comfy-api-liberation") + +# Optional: handle Pydantic URL types (v2 AnyUrl isn't a `str`) +try: + from pydantic.networks import AnyUrl as _PydanticAnyUrl # type: ignore +except Exception: # pragma: no cover + _PydanticAnyUrl = None + +# Track unmapped endpoints we've seen (log once per unique path) +_seen_unmapped: set[str] = set() + +# Store per-request state using ContextVars (safe with concurrent async requests) +_pending_response_transform: ContextVar[Callable | None] = ContextVar( + "liberation_pending_response_transform", + default=None, +) +_current_provider: ContextVar[str | None] = ContextVar("liberation_current_provider", default=None) + + +def get_pending_response_transform() -> Callable | None: + """Get and clear the pending response transform.""" + transform = _pending_response_transform.get() + _pending_response_transform.set(None) + return transform + + +def get_current_provider() -> str | None: + return _current_provider.get() + + +def _materialize_asset_uris_as_data_urls(obj: Any) -> Any: + """ + Replace `liberation://asset/` URIs with `data:;base64,...` URLs. + + This is a generic compatibility mode for providers that accept image/video/document URLs + but can't fetch local assets. Provider-specific transforms can override this with a more + idiomatic integration (multipart, vendor uploads, etc.). + """ + if isinstance(obj, dict): + for k, v in list(obj.items()): + obj[k] = _materialize_asset_uris_as_data_urls(v) + return obj + + if isinstance(obj, list): + for i in range(len(obj)): + obj[i] = _materialize_asset_uris_as_data_urls(obj[i]) + return obj + + is_pydantic_url = _PydanticAnyUrl is not None and isinstance(obj, _PydanticAnyUrl) + if isinstance(obj, str) or is_pydantic_url: + s = str(obj) + asset_id = vault.parse_asset_uri(s) + if asset_id: + return vault.to_data_url(asset_id) + # Ensure JSON-serializable output for AnyUrl values. + return s if is_pydantic_url else obj + + return obj + + +def rewrite_request(cfg: Any) -> Any: + """ + Rewrite request config if path matches a known proxy endpoint. + + Args: + cfg: _RequestConfig from client.py + + Returns: + Modified cfg with absolute vendor URL and auth headers, or original cfg + """ + path = cfg.endpoint.path + + # Only process /proxy/* paths + if not path.startswith("/proxy/"): + _current_provider.set(None) + _pending_response_transform.set(None) + return cfg + + log.debug(f"[liberation] Intercepted: {path}") + + _pending_response_transform.set(None) # Reset for this request + _current_provider.set(None) + + # Try each mapping pattern + for pattern, mapping in mappings.ENDPOINT_MAP.items(): + match = re.match(pattern, path) + if match: + provider = mapping['provider'] + _current_provider.set(provider) + key = config.get_api_key(provider) + + if not key: + # No key configured - return special error that frontend will intercept + log.warning(f"[liberation] NO API KEY for '{provider}' - blocking request") + # Use a structured error message the frontend can parse + raise Exception( + f"LIBERATION_MISSING_KEY:{provider}:API key required for '{provider}'. " + f"Configure your {provider.upper()} API key to use this node directly." + ) + + # Build new absolute URL from template + groups = match.groups() + new_url = mapping['url_template'].format(*groups) + + # Get auth headers + auth_headers = mapping['auth_fn'](key) + + # Apply request transform if defined + new_data = cfg.data + if mapping.get('request_transform'): + try: + new_data = mapping['request_transform'](cfg.data) + except Exception as e: + log.warning(f"Request transform failed for {provider}: {e}") + + # Generic asset materialization: convert asset URIs to data URLs. + # Google is handled with a provider-specific inlineData transform. + if provider != "google": + try: + new_data = _materialize_asset_uris_as_data_urls(new_data) + except Exception as e: + log.warning(f"Asset materialization failed for {provider}: {e}") + + # Store response transform for later application + if mapping.get('response_transform'): + _pending_response_transform.set(mapping['response_transform']) + + # Create new endpoint with absolute URL and merged headers + new_headers = {**(cfg.endpoint.headers or {}), **auth_headers} + + new_endpoint = ApiEndpoint( + path=new_url, + method=cfg.endpoint.method, + query_params=cfg.endpoint.query_params, + headers=new_headers, + ) + + log.info(f"[liberation] {path} → {new_url}") + return replace(cfg, endpoint=new_endpoint, data=new_data) + + # No mapping found - log once per unique path pattern + if path not in _seen_unmapped: + _seen_unmapped.add(path) + log.debug(f"[liberation] Unmapped proxy: {path} - using Comfy proxy") + + return cfg diff --git a/routes.py b/routes.py new file mode 100644 index 0000000..14ddd53 --- /dev/null +++ b/routes.py @@ -0,0 +1,95 @@ +""" +REST API routes for key management. +Endpoints: /api/liberation/* +""" +import logging +from aiohttp import web + +# Import at module level to avoid hot-reload issues with relative imports +from . import config +from . import discovery +from . import mappings +from . import patches + +log = logging.getLogger("comfy-api-liberation") + + +def register_routes(): + """Register API routes with ComfyUI's server.""" + try: + from server import PromptServer + routes = PromptServer.instance.routes + except Exception as e: + log.warning(f"Could not get PromptServer routes: {e}") + return + + @routes.get("/api/liberation/keys") + async def list_keys(request): + """List all providers and their key status (not actual keys).""" + return web.json_response(config.get_all_key_status()) + + @routes.post("/api/liberation/keys/{provider}") + async def set_key(request): + """Set API key for a provider.""" + provider = request.match_info['provider'] + + if provider not in config.PROVIDERS: + return web.json_response( + {"error": f"Unknown provider: {provider}"}, + status=400 + ) + + try: + data = await request.json() + key = data.get('key') + if not key: + return web.json_response({"error": "No key provided"}, status=400) + + config.set_api_key(provider, key) + return web.json_response({"status": "ok", "provider": provider}) + except Exception as e: + log.error(f"Error setting key: {e}") + return web.json_response({"error": str(e)}, status=500) + + @routes.delete("/api/liberation/keys/{provider}") + async def delete_key(request): + """Delete API key for a provider.""" + provider = request.match_info['provider'] + + if provider not in config.PROVIDERS: + return web.json_response( + {"error": f"Unknown provider: {provider}"}, + status=400 + ) + + try: + config.delete_api_key(provider) + return web.json_response({"status": "ok", "provider": provider}) + except Exception as e: + log.error(f"Error deleting key: {e}") + return web.json_response({"error": str(e)}, status=500) + + @routes.get("/api/liberation/status") + async def status(request): + """Get extension status.""" + unmapped = list(discovery.detect_unmapped_endpoints(mappings.ENDPOINT_MAP)) + + return web.json_response({ + "patched": patches.is_patched(), + "mapped_patterns": len(mappings.ENDPOINT_MAP), + "unmapped_endpoints": unmapped, + "unmapped_count": len(unmapped), + }) + + @routes.get("/api/liberation/coverage") + async def coverage(request): + """Get detailed coverage report.""" + report = discovery.get_proxy_coverage_report(mappings.ENDPOINT_MAP) + return web.json_response({"report": report}) + + @routes.get("/api/liberation/providers") + async def list_providers(request): + """List all supported providers.""" + return web.json_response(config.PROVIDERS) + + log.info("Liberation API routes registered") diff --git a/src/web/liberation.ts b/src/web/liberation.ts new file mode 100644 index 0000000..a07655e --- /dev/null +++ b/src/web/liberation.ts @@ -0,0 +1,669 @@ +/** + * ComfyUI API Liberation - Frontend Extension + * Adds API key management UI to ComfyUI + * + * Bypasses ComfyUI's credit-laundering proxy system by allowing users + * to configure their own API keys for direct vendor access. + */ + +// @ts-ignore - ComfyUI runtime imports +import { app } from "../../scripts/app.js"; +// @ts-ignore - ComfyUI runtime imports +import { api } from "../../scripts/api.js"; + +interface Extension { + name: string; + commands?: Command[]; + setup?: () => Promise; +} + +interface Command { + id: string; + label: string; + icon: string; + function: () => void; +} + +interface KeyStatus { + configured: boolean; + source?: string; +} + +interface StatusData { + patched: boolean; + mapped_patterns: number; + unmapped_endpoints: string[]; + unmapped_count: number; +} + +type KeysData = Record; + +// ============================================================================= +// Assets / Constants +// ============================================================================= + +const CSS_URL = new URL("./liberation.css", import.meta.url).href; +const LIBERATION_ERR = /LIBERATION_(MISSING_KEY|INVALID_KEY):([a-z0-9_]+):/i; + +// Inline critical CSS so the modal stays usable even if the stylesheet fails to load. +const INLINE_STYLE_ID = "liberation-inline-style"; +const INLINE_CSS = ` +.liberation-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:999999} +.liberation-modal{background:#1a1a2e;border:1px solid #4a4a6a;border-radius:8px;width:90%;max-width:700px;max-height:85vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px rgba(0,0,0,.5)} +.liberation-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;background:#16162a;border-bottom:1px solid #4a4a6a} +.liberation-header h2{margin:0;font-size:18px;color:#fff} +.liberation-close{background:none;border:none;color:#888;font-size:24px;cursor:pointer;padding:0 8px} +.liberation-content{padding:20px;overflow-y:auto;color:#ccc} +`; + +// ============================================================================= +// Provider API Key URLs - Direct links to get API keys from each vendor +// ============================================================================= + +const PROVIDER_KEY_URLS: Record = { + google: "https://aistudio.google.com/app/apikey", + openai: "https://platform.openai.com/api-keys", + stability: "https://platform.stability.ai/account/keys", + bfl: "https://api.bfl.ml", + ideogram: "https://ideogram.ai/manage-api", + recraft: "https://www.recraft.ai/docs/api-reference/getting-started", + luma: "https://lumalabs.ai/dream-machine/api/keys", + runway: "https://dev.runwayml.com", + kling: "https://app.klingai.com/global/dev/api-key", + minimax: "https://platform.minimax.io", + pika: "https://pika.art/api", + tripo: "https://platform.tripo3d.ai", + rodin: "https://hyperhuman.deemos.com/api-dashboard", + topaz: "https://www.topazlabs.com/api", + byteplus: "https://console.byteplus.com", + pixverse: "https://platform.pixverse.ai", + vidu: "https://platform.vidu.com", + moonvalley: "https://www.moonvalley.com", + ltx: "https://ltx.io/model/api", + wan: "https://www.alibabacloud.com/help/en/model-studio/get-api-key", +}; + +/** + * Get the API key signup URL for a provider, or null if unknown. + */ +function getProviderKeyUrl(provider: string): string | null { + return PROVIDER_KEY_URLS[provider.toLowerCase()] || null; +} + +// ============================================================================= +// Modal System +// ============================================================================= + +function ensureStylesLoaded(): void { + if (!document.getElementById(INLINE_STYLE_ID)) { + const style = document.createElement("style"); + style.id = INLINE_STYLE_ID; + style.textContent = INLINE_CSS; + document.head.appendChild(style); + } + + if (document.querySelector('link[data-liberation-css="true"]')) return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = CSS_URL; + link.dataset.liberationCss = "true"; + link.addEventListener("error", () => { + console.warn("[liberation] Failed to load liberation.css, using inline fallback styles only."); + }); + document.head.appendChild(link); +} + +function createModal(title: string, content: string, options?: { highlight?: string; onClose?: () => void }): HTMLElement { + ensureStylesLoaded(); + // Remove existing modal + document.getElementById("liberation-modal")?.remove(); + + const overlay = document.createElement("div"); + overlay.id = "liberation-modal"; + overlay.className = "liberation-overlay"; + // Super high z-index to be above everything + overlay.style.zIndex = "999999"; + overlay.innerHTML = ` +
+
+

${escapeHtml(title)}

+ +
+
+ ${content} +
+
+ `; + + // Close handlers + const closeModal = () => { + overlay.remove(); + _popupShowing = false; + options?.onClose?.(); + }; + + const closeBtn = overlay.querySelector(".liberation-close") as HTMLElement; + closeBtn.onclick = closeModal; + overlay.onclick = (e) => { + if (e.target === overlay) closeModal(); + }; + + document.body.appendChild(overlay); + + // Highlight specific provider if requested + if (options?.highlight) { + const row = overlay.querySelector(`.key-row[data-provider="${options.highlight}"]`); + if (row) { + row.classList.add("highlighted"); + row.scrollIntoView({ behavior: "smooth", block: "center" }); + const input = row.querySelector(".key-input") as HTMLInputElement; + input?.focus(); + } + } + + return overlay; +} + +function escapeHtml(text: string): string { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} + +// ============================================================================= +// API Key Configuration Popup (for missing key errors) +// ============================================================================= + +async function showKeyConfigPopup( + provider: string, + opts?: { reason?: "missing" | "invalid"; details?: string }, +): Promise { + console.log(`[liberation] Showing key config popup for: ${provider}`); + + // Prevent duplicate popups + if (_popupShowing) return; + _popupShowing = true; + + // Remove any existing liberation modal first + document.getElementById("liberation-modal")?.remove(); + + const keyUrl = getProviderKeyUrl(provider); + const getKeyLink = keyUrl + ? `Get ${provider.toUpperCase()} API Key →` + : ""; + + const reasonLine = + opts?.reason === "invalid" + ? `

Your saved key was rejected. Paste a new one below.

` + : ""; + const detailsLine = opts?.details + ? `

${escapeHtml(opts.details)}

` + : ""; + + const modal = createModal(`Configure ${provider.toUpperCase()} API Key`, ` +
+

+ This node requires a ${provider.toUpperCase()} API key to work. +

+ Enter your API key below to bypass ComfyUI's credit system and use the API directly. +

+ + ${reasonLine} + ${detailsLine} + + ${getKeyLink ? `
${getKeyLink}
` : ""} + +
+ + +
+ +

+ Your key is stored locally and never sent to ComfyUI servers. +
+ Manage all API keys... +

+
+ `); + + // Wire up event handlers + const saveBtn = modal.querySelector("#liberation-save-key") as HTMLButtonElement; + const keyInput = modal.querySelector("#liberation-key-input") as HTMLInputElement; + const showAllLink = modal.querySelector("#show-all-keys") as HTMLAnchorElement; + + saveBtn.onclick = async () => { + const key = keyInput.value.trim(); + if (!key) { + alert("Please enter an API key"); + return; + } + + saveBtn.disabled = true; + saveBtn.textContent = "Saving..."; + + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to save key"); + } + + console.log(`[liberation] Saved key for ${provider}`); + modal.remove(); + _popupShowing = false; + + // Show success and suggest re-running + showSuccessToast(`${provider.toUpperCase()} API key saved! Re-queue your prompt to continue.`); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + saveBtn.disabled = false; + saveBtn.textContent = "Save & Retry"; + } + }; + + // Enter key to save + keyInput.onkeydown = (e) => { + if (e.key === "Enter") saveBtn.click(); + }; + + showAllLink.onclick = (e) => { + e.preventDefault(); + modal.remove(); + showKeyManager(); + }; +} + +function showSuccessToast(message: string): void { + const toast = document.createElement("div"); + toast.className = "liberation-toast"; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 4000); +} + +// ============================================================================= +// Error Interception +// ============================================================================= + +let _popupShowing = false; +let _lastPopupTime = 0; +let _dispatchPatched = false; + +function parseLiberationError(text: string): { kind: "MISSING_KEY" | "INVALID_KEY"; provider: string; details: string } | null { + const match = text.match(LIBERATION_ERR); + if (!match) return null; + const kind = match[1].toUpperCase() as "MISSING_KEY" | "INVALID_KEY"; + const provider = match[2].toLowerCase(); + const details = text.replace(match[0], "").trim(); + return { kind, provider, details }; +} + +function setupErrorInterception(): void { + console.log("[liberation] Setting up error interception..."); + + // Watch for DOM changes to catch error dialogs + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkForLiberationError(node); + } + } + } + }); + + observer.observe(document.body, { + childList: true, + subtree: true, + }); + + // Also listen for API events + api.addEventListener("execution_error", (event: CustomEvent) => { + const error = event.detail?.exception_message || event.detail?.message || ""; + handlePotentialLiberationError(error); + }); + + console.log("[liberation] Error interception active"); +} + +function checkForLiberationError(element: HTMLElement): void { + // Debounce - don't fire multiple times + const now = Date.now(); + if (_popupShowing || now - _lastPopupTime < 1000) return; + + // Look for error dialogs containing our marker + const text = element.textContent || ""; + const parsed = parseLiberationError(text); + if (!parsed) return; + + _lastPopupTime = now; + console.log(`[liberation] Found liberation error dialog: ${parsed.kind} (${parsed.provider})`); + + // Hide ALL ComfyUI dialogs immediately (and keep them suppressed briefly) + suppressComfyDialogsFor(); + + showKeyConfigPopup(parsed.provider, { + reason: parsed.kind === "INVALID_KEY" ? "invalid" : "missing", + details: parsed.details, + }); +} + +function hideAllComfyDialogs(): void { + // Hide all possible ComfyUI error dialogs + const selectors = [ + ".comfy-modal", + ".p-dialog", + "[class*='global-dialog']", + ]; + + for (const selector of selectors) { + document.querySelectorAll(selector).forEach((el) => { + if (el instanceof HTMLElement && !el.id?.includes("liberation")) { + el.style.display = "none"; + el.remove(); // Actually remove it + } + }); + } + + // Also hide any overlay/mask + document.querySelectorAll(".p-dialog-mask, .comfy-modal-overlay").forEach((el) => { + if (el instanceof HTMLElement) { + el.style.display = "none"; + el.remove(); + } + }); +} + +function handlePotentialLiberationError(errorMessage: string): void { + // Debounce + const now = Date.now(); + if (_popupShowing || now - _lastPopupTime < 1000) return; + + const parsed = parseLiberationError(errorMessage); + if (!parsed) return; + + _lastPopupTime = now; + console.log(`[liberation] Intercepted liberation error: ${parsed.kind} (${parsed.provider})`); + suppressComfyDialogsFor(); + showKeyConfigPopup(parsed.provider, { + reason: parsed.kind === "INVALID_KEY" ? "invalid" : "missing", + details: parsed.details, + }); +} + +function suppressComfyDialogsFor(ms = 1200): void { + const end = Date.now() + ms; + const tick = () => { + hideAllComfyDialogs(); + if (Date.now() < end) { + setTimeout(tick, 120); + } + }; + tick(); +} + +function parseProviderFromMessage(msg?: string | null): string | null { + const parsed = msg ? parseLiberationError(msg) : null; + return parsed ? parsed.provider : null; +} + +function installDispatchInterceptor(): void { + if (_dispatchPatched) return; + const origDispatch = (api as any).dispatchCustomEvent?.bind(api); + if (!origDispatch) return; + + (api as any).dispatchCustomEvent = function patchedDispatch(type: string, detail?: any) { + if (type === "execution_error") { + const provider = parseProviderFromMessage(detail?.exception_message || ""); + if (provider) { + _lastPopupTime = Date.now(); + suppressComfyDialogsFor(); + showKeyConfigPopup(provider); + return true; // swallow so default Comfy dialog doesn't render + } + } + return origDispatch(type, detail); + }; + + _dispatchPatched = true; + console.log("[liberation] Patched api.dispatchCustomEvent for execution_error interception"); +} + +// ============================================================================= +// Full Key Manager +// ============================================================================= + +async function showKeyManager(highlightProvider?: string): Promise { + console.log("[liberation] Opening key manager..."); + + let keysData: KeysData; + let statusData: StatusData; + + try { + const [keysResp, statusResp] = await Promise.all([ + api.fetchApi("/api/liberation/keys"), + api.fetchApi("/api/liberation/status"), + ]); + + if (!keysResp.ok) throw new Error(`Keys API returned ${keysResp.status}`); + if (!statusResp.ok) throw new Error(`Status API returned ${statusResp.status}`); + + keysData = await keysResp.json(); + statusData = await statusResp.json(); + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + console.error("[liberation] Failed to load data:", error); + createModal("Error", `

Failed to load API key data: ${escapeHtml(error)}

`); + return; + } + + const providersHtml = Object.entries(keysData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, info]) => { + const statusClass = info.configured ? "configured" : "missing"; + const statusText = info.configured ? "Configured" : "Not set"; + const highlightClass = provider === highlightProvider ? "highlighted" : ""; + const keyUrl = getProviderKeyUrl(provider); + const getKeyLink = keyUrl + ? `Get Key` + : ""; + + return ` +
+
+ ${escapeHtml(provider)} + ${getKeyLink} + ${statusText} +
+
+ + + +
+
+ `; + }) + .join(""); + + const unmappedHtml = statusData.unmapped_endpoints.length > 0 + ? `
+ ${statusData.unmapped_count} unmapped endpoints +
    + ${statusData.unmapped_endpoints.slice(0, 20).map(ep => `
  • ${escapeHtml(ep)}
  • `).join("")} + ${statusData.unmapped_count > 20 ? `
  • ... and ${statusData.unmapped_count - 20} more
  • ` : ""} +
+
` + : "

All endpoints mapped!

"; + + const modal = createModal("API Key Manager", ` +
+ + ${statusData.patched ? "Active" : "Inactive"} Direct Routing + + + ${statusData.mapped_patterns} providers mapped + +
+ +
+

API Keys

+

Enter your API keys below. Keys are saved locally.

+ ${providersHtml} +
+ +
+

Coverage

+ ${unmappedHtml} +
+ `, { highlight: highlightProvider }); + + // Attach event listeners + modal.querySelectorAll(".btn-save").forEach(btn => { + btn.onclick = () => saveKey(btn.dataset.provider!); + }); + modal.querySelectorAll(".btn-delete").forEach(btn => { + btn.onclick = () => deleteKey(btn.dataset.provider!); + }); +} + +async function saveKey(provider: string): Promise { + const input = document.querySelector( + `.key-row[data-provider="${provider}"] .key-input` + ); + const key = input?.value?.trim(); + + if (!key) { + alert("Please enter an API key"); + return; + } + + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to save key"); + } + + console.log(`[liberation] Saved key for ${provider}`); + showKeyManager(); // Refresh + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + } +} + +async function deleteKey(provider: string): Promise { + if (!confirm(`Clear API key for ${provider}?`)) return; + + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "DELETE", + }); + + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to delete key"); + } + + console.log(`[liberation] Deleted key for ${provider}`); + showKeyManager(); // Refresh + } catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + } +} + +// ============================================================================= +// Extension Registration +// ============================================================================= + +app.registerExtension({ + name: "comfy.api.liberation", + + commands: [ + { + id: "Liberation.APIKeys.Show", + label: "API Key Manager", + icon: "pi pi-key", + function: showKeyManager, + } + ], + + async setup() { + console.log("[liberation] Setting up frontend extension..."); + ensureStylesLoaded(); + installDispatchInterceptor(); + + // Set up error interception for missing API keys + setupErrorInterception(); + + // Try new-style menu first (ComfyUI 1.3+) + try { + // @ts-ignore - ComfyUI runtime module + const buttonModule = await import("../../scripts/ui/components/button.js"); + const ComfyButton = buttonModule.ComfyButton as new (opts: { + icon: string; + action: () => void; + tooltip: string; + content: string; + classList: string; + }) => { element: HTMLElement }; + + const liberationBtn = new ComfyButton({ + icon: "key", + action: () => showKeyManager(), + tooltip: "API Key Manager (bypass Comfy credits)", + content: "API Keys", + classList: "comfyui-button comfyui-menu-mobile-collapse" + }); + + if (app.menu?.settingsGroup?.element) { + app.menu.settingsGroup.element.before(liberationBtn.element); + console.log("[liberation] Button added to new-style menu"); + } + } catch (e) { + console.log("[liberation] New menu not available:", e); + } + + // Also add to legacy menu + const menu = document.querySelector(".comfy-menu"); + if (menu) { + const btn = document.createElement("button"); + btn.id = "liberation-menu-btn"; + btn.textContent = "API Keys"; + btn.title = "Manage direct API keys (bypass Comfy credits)"; + btn.onclick = () => showKeyManager(); + btn.style.cssText = ` + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + margin: 4px; + `; + menu.appendChild(btn); + console.log("[liberation] Button added to legacy menu"); + } + + console.log("[liberation] Frontend extension loaded successfully"); + }, +}); diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..c02e454 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,18 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "ESNext", + "moduleResolution": "bundler", + "strict": true, + "noEmit": false, + "outDir": "./web", + "rootDir": "./src/web", + "declaration": false, + "esModuleInterop": true, + "skipLibCheck": true, + "isolatedModules": true, + "noImplicitAny": false + }, + "include": ["src/web/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/web/liberation.css b/web/liberation.css new file mode 100644 index 0000000..c049585 --- /dev/null +++ b/web/liberation.css @@ -0,0 +1,368 @@ +/* ComfyUI API Liberation - Styles */ + +/* Modal overlay */ +.liberation-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.7); + display: flex; + align-items: center; + justify-content: center; + z-index: 10000; +} + +/* Modal container */ +.liberation-modal { + background: #1a1a2e; + border: 1px solid #4a4a6a; + border-radius: 8px; + width: 90%; + max-width: 700px; + max-height: 85vh; + overflow: hidden; + display: flex; + flex-direction: column; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.5); +} + +/* Header */ +.liberation-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 16px 20px; + background: #16162a; + border-bottom: 1px solid #4a4a6a; +} + +.liberation-header h2 { + margin: 0; + font-size: 18px; + color: #fff; +} + +.liberation-close { + background: none; + border: none; + color: #888; + font-size: 24px; + cursor: pointer; + padding: 0 8px; +} + +.liberation-close:hover { + color: #fff; +} + +/* Content */ +.liberation-content { + padding: 20px; + overflow-y: auto; + color: #ccc; +} + +.liberation-content h3 { + margin: 0 0 12px 0; + font-size: 14px; + color: #888; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.liberation-content .hint { + font-size: 12px; + color: #666; + margin-bottom: 16px; +} + +/* Status bar */ +.liberation-status-bar { + display: flex; + gap: 16px; + padding: 12px 16px; + background: #12122a; + border-radius: 6px; + margin-bottom: 20px; +} + +.status-item { + font-size: 13px; + color: #888; +} + +.status-item.active { + color: #4ade80; +} + +.status-item.inactive { + color: #f87171; +} + +/* Provider rows */ +.liberation-providers { + margin-bottom: 24px; +} + +.key-row { + display: flex; + flex-direction: column; + gap: 8px; + padding: 12px; + background: #12122a; + border-radius: 6px; + margin-bottom: 8px; +} + +.key-info { + display: flex; + align-items: center; + gap: 12px; +} + +.provider-name { + font-weight: 600; + color: #fff; + min-width: 100px; +} + +.status { + margin-left: auto; + font-size: 12px; + padding: 2px 8px; + border-radius: 4px; +} + +.status.configured { + color: #4ade80; + background: rgba(74, 222, 128, 0.1); +} + +.status.missing { + color: #888; +} + +/* Key actions */ +.key-actions { + display: flex; + gap: 8px; +} + +.key-input { + flex: 1; + padding: 8px 12px; + border: 1px solid #3a3a5a; + border-radius: 4px; + background: #0a0a1a; + color: #fff; + font-family: monospace; + font-size: 13px; +} + +.key-input:focus { + outline: none; + border-color: #6366f1; +} + +.btn-save, +.btn-delete { + padding: 8px 16px; + border: none; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + transition: background 0.2s; +} + +.btn-save { + background: #4f46e5; + color: #fff; +} + +.btn-save:hover { + background: #6366f1; +} + +.btn-delete { + background: #3a3a5a; + color: #ccc; +} + +.btn-delete:hover { + background: #ef4444; + color: #fff; +} + +/* Unmapped section */ +.liberation-unmapped { + border-top: 1px solid #3a3a5a; + padding-top: 20px; +} + +.liberation-unmapped details { + background: #12122a; + border-radius: 6px; + padding: 12px; +} + +.liberation-unmapped summary { + cursor: pointer; + color: #f59e0b; +} + +.unmapped-list { + margin: 12px 0 0 0; + padding-left: 20px; + font-size: 12px; + color: #666; + font-family: monospace; +} + +/* Menu button */ +#liberation-menu-btn { + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: #fff; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + font-size: 13px; + margin: 4px; + transition: opacity 0.2s; +} + +#liberation-menu-btn:hover { + opacity: 0.9; +} + +/* Key config popup */ +.liberation-key-prompt { + text-align: center; +} + +.liberation-key-prompt .prompt-message { + font-size: 14px; + color: #ccc; + margin-bottom: 20px; +} + +.liberation-key-prompt .prompt-message strong { + color: #6366f1; +} + +.key-input-group { + display: flex; + gap: 12px; + margin-bottom: 20px; +} + +.key-input.large { + flex: 1; + padding: 12px 16px; + font-size: 14px; +} + +.btn-save.large { + padding: 12px 24px; + font-size: 14px; + white-space: nowrap; +} + +/* Highlighted row */ +.key-row.highlighted { + background: rgba(99, 102, 241, 0.2); + border: 1px solid #6366f1; +} + +/* Toast notification */ +.liberation-toast { + position: fixed; + bottom: 80px; + left: 50%; + transform: translateX(-50%); + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: white; + padding: 12px 24px; + border-radius: 8px; + font-size: 14px; + z-index: 10001; + animation: toastIn 0.3s ease, toastOut 0.3s ease 3.7s forwards; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); +} + +@keyframes toastIn { + from { + opacity: 0; + transform: translateX(-50%) translateY(20px); + } + to { + opacity: 1; + transform: translateX(-50%) translateY(0); + } +} + +@keyframes toastOut { + from { + opacity: 1; + transform: translateX(-50%) translateY(0); + } + to { + opacity: 0; + transform: translateX(-50%) translateY(-20px); + } +} + +/* Links in hints */ +.hint a { + color: #6366f1; + text-decoration: none; +} + +.hint a:hover { + text-decoration: underline; +} + +/* Get API Key section in error popup */ +.get-key-section { + text-align: center; + margin-bottom: 20px; +} + +.get-key-link { + display: inline-block; + background: linear-gradient(135deg, #10b981, #059669); + color: white; + padding: 10px 20px; + border-radius: 6px; + text-decoration: none; + font-weight: 600; + font-size: 14px; + transition: opacity 0.2s, transform 0.2s; +} + +.get-key-link:hover { + opacity: 0.9; + transform: translateY(-1px); + text-decoration: none; +} + +/* Get Key button in key manager rows */ +.btn-get-key { + display: inline-block; + background: #10b981; + color: white; + padding: 2px 8px; + border-radius: 4px; + text-decoration: none; + font-size: 11px; + font-weight: 500; + margin-left: 8px; + transition: background 0.2s; +} + +.btn-get-key:hover { + background: #059669; + text-decoration: none; +} diff --git a/web/liberation.js b/web/liberation.js new file mode 100644 index 0000000..e1c1e05 --- /dev/null +++ b/web/liberation.js @@ -0,0 +1,560 @@ +/** + * ComfyUI API Liberation - Frontend Extension + * Adds API key management UI to ComfyUI + * + * Bypasses ComfyUI's credit-laundering proxy system by allowing users + * to configure their own API keys for direct vendor access. + */ +// @ts-ignore - ComfyUI runtime imports +import { app } from "../../scripts/app.js"; +// @ts-ignore - ComfyUI runtime imports +import { api } from "../../scripts/api.js"; +// ============================================================================= +// Assets / Constants +// ============================================================================= +const CSS_URL = new URL("./liberation.css", import.meta.url).href; +const LIBERATION_ERR = /LIBERATION_(MISSING_KEY|INVALID_KEY):([a-z0-9_]+):/i; +// Inline critical CSS so the modal stays usable even if the stylesheet fails to load. +const INLINE_STYLE_ID = "liberation-inline-style"; +const INLINE_CSS = ` +.liberation-overlay{position:fixed;inset:0;background:rgba(0,0,0,.7);display:flex;align-items:center;justify-content:center;z-index:999999} +.liberation-modal{background:#1a1a2e;border:1px solid #4a4a6a;border-radius:8px;width:90%;max-width:700px;max-height:85vh;overflow:hidden;display:flex;flex-direction:column;box-shadow:0 4px 20px rgba(0,0,0,.5)} +.liberation-header{display:flex;justify-content:space-between;align-items:center;padding:16px 20px;background:#16162a;border-bottom:1px solid #4a4a6a} +.liberation-header h2{margin:0;font-size:18px;color:#fff} +.liberation-close{background:none;border:none;color:#888;font-size:24px;cursor:pointer;padding:0 8px} +.liberation-content{padding:20px;overflow-y:auto;color:#ccc} +`; +// ============================================================================= +// Provider API Key URLs - Direct links to get API keys from each vendor +// ============================================================================= +const PROVIDER_KEY_URLS = { + google: "https://aistudio.google.com/app/apikey", + openai: "https://platform.openai.com/api-keys", + stability: "https://platform.stability.ai/account/keys", + bfl: "https://api.bfl.ml", + ideogram: "https://ideogram.ai/manage-api", + recraft: "https://www.recraft.ai/docs/api-reference/getting-started", + luma: "https://lumalabs.ai/dream-machine/api/keys", + runway: "https://dev.runwayml.com", + kling: "https://app.klingai.com/global/dev/api-key", + minimax: "https://platform.minimax.io", + pika: "https://pika.art/api", + tripo: "https://platform.tripo3d.ai", + rodin: "https://hyperhuman.deemos.com/api-dashboard", + topaz: "https://www.topazlabs.com/api", + byteplus: "https://console.byteplus.com", + pixverse: "https://platform.pixverse.ai", + vidu: "https://platform.vidu.com", + moonvalley: "https://www.moonvalley.com", + ltx: "https://ltx.io/model/api", + wan: "https://www.alibabacloud.com/help/en/model-studio/get-api-key", +}; +/** + * Get the API key signup URL for a provider, or null if unknown. + */ +function getProviderKeyUrl(provider) { + return PROVIDER_KEY_URLS[provider.toLowerCase()] || null; +} +// ============================================================================= +// Modal System +// ============================================================================= +function ensureStylesLoaded() { + if (!document.getElementById(INLINE_STYLE_ID)) { + const style = document.createElement("style"); + style.id = INLINE_STYLE_ID; + style.textContent = INLINE_CSS; + document.head.appendChild(style); + } + if (document.querySelector('link[data-liberation-css="true"]')) + return; + const link = document.createElement("link"); + link.rel = "stylesheet"; + link.href = CSS_URL; + link.dataset.liberationCss = "true"; + link.addEventListener("error", () => { + console.warn("[liberation] Failed to load liberation.css, using inline fallback styles only."); + }); + document.head.appendChild(link); +} +function createModal(title, content, options) { + ensureStylesLoaded(); + // Remove existing modal + document.getElementById("liberation-modal")?.remove(); + const overlay = document.createElement("div"); + overlay.id = "liberation-modal"; + overlay.className = "liberation-overlay"; + // Super high z-index to be above everything + overlay.style.zIndex = "999999"; + overlay.innerHTML = ` +
+
+

${escapeHtml(title)}

+ +
+
+ ${content} +
+
+ `; + // Close handlers + const closeModal = () => { + overlay.remove(); + _popupShowing = false; + options?.onClose?.(); + }; + const closeBtn = overlay.querySelector(".liberation-close"); + closeBtn.onclick = closeModal; + overlay.onclick = (e) => { + if (e.target === overlay) + closeModal(); + }; + document.body.appendChild(overlay); + // Highlight specific provider if requested + if (options?.highlight) { + const row = overlay.querySelector(`.key-row[data-provider="${options.highlight}"]`); + if (row) { + row.classList.add("highlighted"); + row.scrollIntoView({ behavior: "smooth", block: "center" }); + const input = row.querySelector(".key-input"); + input?.focus(); + } + } + return overlay; +} +function escapeHtml(text) { + const div = document.createElement("div"); + div.textContent = text; + return div.innerHTML; +} +// ============================================================================= +// API Key Configuration Popup (for missing key errors) +// ============================================================================= +async function showKeyConfigPopup(provider, opts) { + console.log(`[liberation] Showing key config popup for: ${provider}`); + // Prevent duplicate popups + if (_popupShowing) + return; + _popupShowing = true; + // Remove any existing liberation modal first + document.getElementById("liberation-modal")?.remove(); + const keyUrl = getProviderKeyUrl(provider); + const getKeyLink = keyUrl + ? `Get ${provider.toUpperCase()} API Key →` + : ""; + const reasonLine = opts?.reason === "invalid" + ? `

Your saved key was rejected. Paste a new one below.

` + : ""; + const detailsLine = opts?.details + ? `

${escapeHtml(opts.details)}

` + : ""; + const modal = createModal(`Configure ${provider.toUpperCase()} API Key`, ` +
+

+ This node requires a ${provider.toUpperCase()} API key to work. +

+ Enter your API key below to bypass ComfyUI's credit system and use the API directly. +

+ + ${reasonLine} + ${detailsLine} + + ${getKeyLink ? `
${getKeyLink}
` : ""} + +
+ + +
+ +

+ Your key is stored locally and never sent to ComfyUI servers. +
+ Manage all API keys... +

+
+ `); + // Wire up event handlers + const saveBtn = modal.querySelector("#liberation-save-key"); + const keyInput = modal.querySelector("#liberation-key-input"); + const showAllLink = modal.querySelector("#show-all-keys"); + saveBtn.onclick = async () => { + const key = keyInput.value.trim(); + if (!key) { + alert("Please enter an API key"); + return; + } + saveBtn.disabled = true; + saveBtn.textContent = "Saving..."; + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to save key"); + } + console.log(`[liberation] Saved key for ${provider}`); + modal.remove(); + _popupShowing = false; + // Show success and suggest re-running + showSuccessToast(`${provider.toUpperCase()} API key saved! Re-queue your prompt to continue.`); + } + catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + saveBtn.disabled = false; + saveBtn.textContent = "Save & Retry"; + } + }; + // Enter key to save + keyInput.onkeydown = (e) => { + if (e.key === "Enter") + saveBtn.click(); + }; + showAllLink.onclick = (e) => { + e.preventDefault(); + modal.remove(); + showKeyManager(); + }; +} +function showSuccessToast(message) { + const toast = document.createElement("div"); + toast.className = "liberation-toast"; + toast.textContent = message; + document.body.appendChild(toast); + setTimeout(() => toast.remove(), 4000); +} +// ============================================================================= +// Error Interception +// ============================================================================= +let _popupShowing = false; +let _lastPopupTime = 0; +let _dispatchPatched = false; +function parseLiberationError(text) { + const match = text.match(LIBERATION_ERR); + if (!match) + return null; + const kind = match[1].toUpperCase(); + const provider = match[2].toLowerCase(); + const details = text.replace(match[0], "").trim(); + return { kind, provider, details }; +} +function setupErrorInterception() { + console.log("[liberation] Setting up error interception..."); + // Watch for DOM changes to catch error dialogs + const observer = new MutationObserver((mutations) => { + for (const mutation of mutations) { + for (const node of mutation.addedNodes) { + if (node instanceof HTMLElement) { + checkForLiberationError(node); + } + } + } + }); + observer.observe(document.body, { + childList: true, + subtree: true, + }); + // Also listen for API events + api.addEventListener("execution_error", (event) => { + const error = event.detail?.exception_message || event.detail?.message || ""; + handlePotentialLiberationError(error); + }); + console.log("[liberation] Error interception active"); +} +function checkForLiberationError(element) { + // Debounce - don't fire multiple times + const now = Date.now(); + if (_popupShowing || now - _lastPopupTime < 1000) + return; + // Look for error dialogs containing our marker + const text = element.textContent || ""; + const parsed = parseLiberationError(text); + if (!parsed) + return; + _lastPopupTime = now; + console.log(`[liberation] Found liberation error dialog: ${parsed.kind} (${parsed.provider})`); + // Hide ALL ComfyUI dialogs immediately (and keep them suppressed briefly) + suppressComfyDialogsFor(); + showKeyConfigPopup(parsed.provider, { + reason: parsed.kind === "INVALID_KEY" ? "invalid" : "missing", + details: parsed.details, + }); +} +function hideAllComfyDialogs() { + // Hide all possible ComfyUI error dialogs + const selectors = [ + ".comfy-modal", + ".p-dialog", + "[class*='global-dialog']", + ]; + for (const selector of selectors) { + document.querySelectorAll(selector).forEach((el) => { + if (el instanceof HTMLElement && !el.id?.includes("liberation")) { + el.style.display = "none"; + el.remove(); // Actually remove it + } + }); + } + // Also hide any overlay/mask + document.querySelectorAll(".p-dialog-mask, .comfy-modal-overlay").forEach((el) => { + if (el instanceof HTMLElement) { + el.style.display = "none"; + el.remove(); + } + }); +} +function handlePotentialLiberationError(errorMessage) { + // Debounce + const now = Date.now(); + if (_popupShowing || now - _lastPopupTime < 1000) + return; + const parsed = parseLiberationError(errorMessage); + if (!parsed) + return; + _lastPopupTime = now; + console.log(`[liberation] Intercepted liberation error: ${parsed.kind} (${parsed.provider})`); + suppressComfyDialogsFor(); + showKeyConfigPopup(parsed.provider, { + reason: parsed.kind === "INVALID_KEY" ? "invalid" : "missing", + details: parsed.details, + }); +} +function suppressComfyDialogsFor(ms = 1200) { + const end = Date.now() + ms; + const tick = () => { + hideAllComfyDialogs(); + if (Date.now() < end) { + setTimeout(tick, 120); + } + }; + tick(); +} +function parseProviderFromMessage(msg) { + const parsed = msg ? parseLiberationError(msg) : null; + return parsed ? parsed.provider : null; +} +function installDispatchInterceptor() { + if (_dispatchPatched) + return; + const origDispatch = api.dispatchCustomEvent?.bind(api); + if (!origDispatch) + return; + api.dispatchCustomEvent = function patchedDispatch(type, detail) { + if (type === "execution_error") { + const provider = parseProviderFromMessage(detail?.exception_message || ""); + if (provider) { + _lastPopupTime = Date.now(); + suppressComfyDialogsFor(); + showKeyConfigPopup(provider); + return true; // swallow so default Comfy dialog doesn't render + } + } + return origDispatch(type, detail); + }; + _dispatchPatched = true; + console.log("[liberation] Patched api.dispatchCustomEvent for execution_error interception"); +} +// ============================================================================= +// Full Key Manager +// ============================================================================= +async function showKeyManager(highlightProvider) { + console.log("[liberation] Opening key manager..."); + let keysData; + let statusData; + try { + const [keysResp, statusResp] = await Promise.all([ + api.fetchApi("/api/liberation/keys"), + api.fetchApi("/api/liberation/status"), + ]); + if (!keysResp.ok) + throw new Error(`Keys API returned ${keysResp.status}`); + if (!statusResp.ok) + throw new Error(`Status API returned ${statusResp.status}`); + keysData = await keysResp.json(); + statusData = await statusResp.json(); + } + catch (e) { + const error = e instanceof Error ? e.message : String(e); + console.error("[liberation] Failed to load data:", error); + createModal("Error", `

Failed to load API key data: ${escapeHtml(error)}

`); + return; + } + const providersHtml = Object.entries(keysData) + .sort(([a], [b]) => a.localeCompare(b)) + .map(([provider, info]) => { + const statusClass = info.configured ? "configured" : "missing"; + const statusText = info.configured ? "Configured" : "Not set"; + const highlightClass = provider === highlightProvider ? "highlighted" : ""; + const keyUrl = getProviderKeyUrl(provider); + const getKeyLink = keyUrl + ? `Get Key` + : ""; + return ` +
+
+ ${escapeHtml(provider)} + ${getKeyLink} + ${statusText} +
+
+ + + +
+
+ `; + }) + .join(""); + const unmappedHtml = statusData.unmapped_endpoints.length > 0 + ? `
+ ${statusData.unmapped_count} unmapped endpoints +
    + ${statusData.unmapped_endpoints.slice(0, 20).map(ep => `
  • ${escapeHtml(ep)}
  • `).join("")} + ${statusData.unmapped_count > 20 ? `
  • ... and ${statusData.unmapped_count - 20} more
  • ` : ""} +
+
` + : "

All endpoints mapped!

"; + const modal = createModal("API Key Manager", ` +
+ + ${statusData.patched ? "Active" : "Inactive"} Direct Routing + + + ${statusData.mapped_patterns} providers mapped + +
+ +
+

API Keys

+

Enter your API keys below. Keys are saved locally.

+ ${providersHtml} +
+ +
+

Coverage

+ ${unmappedHtml} +
+ `, { highlight: highlightProvider }); + // Attach event listeners + modal.querySelectorAll(".btn-save").forEach(btn => { + btn.onclick = () => saveKey(btn.dataset.provider); + }); + modal.querySelectorAll(".btn-delete").forEach(btn => { + btn.onclick = () => deleteKey(btn.dataset.provider); + }); +} +async function saveKey(provider) { + const input = document.querySelector(`.key-row[data-provider="${provider}"] .key-input`); + const key = input?.value?.trim(); + if (!key) { + alert("Please enter an API key"); + return; + } + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ key }), + }); + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to save key"); + } + console.log(`[liberation] Saved key for ${provider}`); + showKeyManager(); // Refresh + } + catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + } +} +async function deleteKey(provider) { + if (!confirm(`Clear API key for ${provider}?`)) + return; + try { + const resp = await api.fetchApi(`/api/liberation/keys/${provider}`, { + method: "DELETE", + }); + if (!resp.ok) { + const err = await resp.json(); + throw new Error(err.error || "Failed to delete key"); + } + console.log(`[liberation] Deleted key for ${provider}`); + showKeyManager(); // Refresh + } + catch (e) { + const error = e instanceof Error ? e.message : String(e); + alert(`Error: ${error}`); + } +} +// ============================================================================= +// Extension Registration +// ============================================================================= +app.registerExtension({ + name: "comfy.api.liberation", + commands: [ + { + id: "Liberation.APIKeys.Show", + label: "API Key Manager", + icon: "pi pi-key", + function: showKeyManager, + } + ], + async setup() { + console.log("[liberation] Setting up frontend extension..."); + ensureStylesLoaded(); + installDispatchInterceptor(); + // Set up error interception for missing API keys + setupErrorInterception(); + // Try new-style menu first (ComfyUI 1.3+) + try { + // @ts-ignore - ComfyUI runtime module + const buttonModule = await import("../../scripts/ui/components/button.js"); + const ComfyButton = buttonModule.ComfyButton; + const liberationBtn = new ComfyButton({ + icon: "key", + action: () => showKeyManager(), + tooltip: "API Key Manager (bypass Comfy credits)", + content: "API Keys", + classList: "comfyui-button comfyui-menu-mobile-collapse" + }); + if (app.menu?.settingsGroup?.element) { + app.menu.settingsGroup.element.before(liberationBtn.element); + console.log("[liberation] Button added to new-style menu"); + } + } + catch (e) { + console.log("[liberation] New menu not available:", e); + } + // Also add to legacy menu + const menu = document.querySelector(".comfy-menu"); + if (menu) { + const btn = document.createElement("button"); + btn.id = "liberation-menu-btn"; + btn.textContent = "API Keys"; + btn.title = "Manage direct API keys (bypass Comfy credits)"; + btn.onclick = () => showKeyManager(); + btn.style.cssText = ` + background: linear-gradient(135deg, #4f46e5, #7c3aed); + color: white; + border: none; + padding: 6px 12px; + border-radius: 4px; + cursor: pointer; + margin: 4px; + `; + menu.appendChild(btn); + console.log("[liberation] Button added to legacy menu"); + } + console.log("[liberation] Frontend extension loaded successfully"); + }, +});