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
This commit is contained in:
nuck
2026-01-22 00:32:10 -05:00
commit 1b3e288e2c
39 changed files with 3917 additions and 0 deletions
+39
View File
@@ -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/
+40
View File
@@ -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
+21
View File
@@ -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.
+115
View File
@@ -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.
+78
View File
@@ -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",
]
+121
View File
@@ -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/<id>`). 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())
+109
View File
@@ -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
+95
View File
@@ -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)
+77
View File
@@ -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()
+46
View File
@@ -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,
},
}
+25
View File
@@ -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,
},
}
+152
View File
@@ -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/<id>` 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,
},
}
+25
View File
@@ -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,
},
}
+86
View File
@@ -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,
},
}
+18
View File
@@ -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,
},
}
+25
View File
@@ -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,
},
}
+25
View File
@@ -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,
},
}
+46
View File
@@ -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,
},
}
+56
View File
@@ -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,
},
}
+69
View File
@@ -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,
},
}
+39
View File
@@ -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,
},
}
+60
View File
@@ -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,
},
}
+25
View File
@@ -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,
},
}
+32
View File
@@ -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,
},
}
+72
View File
@@ -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,
},
}
+54
View File
@@ -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,
},
}
+18
View File
@@ -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,
},
}
+39
View File
@@ -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,
},
}
+32
View File
@@ -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,
},
}
+148
View File
@@ -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",
}
+29
View File
@@ -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"
}
}
}
}
+13
View File
@@ -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"
}
}
+217
View File
@@ -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")
+161
View File
@@ -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/<id>` URIs with `data:<mime>;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
+95
View File
@@ -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")
+669
View File
@@ -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<void>;
}
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<string, KeyStatus>;
// =============================================================================
// 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<string, string> = {
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 = `
<div class="liberation-modal">
<div class="liberation-header">
<h2>${escapeHtml(title)}</h2>
<button class="liberation-close">&times;</button>
</div>
<div class="liberation-content">
${content}
</div>
</div>
`;
// 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<void> {
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
? `<a href="${keyUrl}" target="_blank" rel="noopener" class="get-key-link">Get ${provider.toUpperCase()} API Key &rarr;</a>`
: "";
const reasonLine =
opts?.reason === "invalid"
? `<p class="prompt-message" style="color:#f87171;margin-top:-8px;">Your saved key was rejected. Paste a new one below.</p>`
: "";
const detailsLine = opts?.details
? `<p class="hint" style="color:#9ca3af;margin-top:0;">${escapeHtml(opts.details)}</p>`
: "";
const modal = createModal(`Configure ${provider.toUpperCase()} API Key`, `
<div class="liberation-key-prompt">
<p class="prompt-message">
This node requires a <strong>${provider.toUpperCase()}</strong> API key to work.
<br><br>
Enter your API key below to bypass ComfyUI's credit system and use the API directly.
</p>
${reasonLine}
${detailsLine}
${getKeyLink ? `<div class="get-key-section">${getKeyLink}</div>` : ""}
<div class="key-input-group">
<input type="password"
id="liberation-key-input"
class="key-input large"
placeholder="Enter your ${provider.toUpperCase()} API key..."
autofocus>
<button id="liberation-save-key" class="btn-save large">Save & Retry</button>
</div>
<p class="hint">
Your key is stored locally and never sent to ComfyUI servers.
<br>
<a href="#" id="show-all-keys">Manage all API keys...</a>
</p>
</div>
`);
// 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<void> {
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", `<p style="color: #f87171;">Failed to load API key data: ${escapeHtml(error)}</p>`);
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
? `<a href="${keyUrl}" target="_blank" rel="noopener" class="btn-get-key" title="Get API key from ${provider}">Get Key</a>`
: "";
return `
<div class="key-row ${highlightClass}" data-provider="${escapeHtml(provider)}">
<div class="key-info">
<span class="provider-name">${escapeHtml(provider)}</span>
${getKeyLink}
<span class="status ${statusClass}">${statusText}</span>
</div>
<div class="key-actions">
<input type="password"
class="key-input"
placeholder="Enter API key..."
data-provider="${escapeHtml(provider)}">
<button class="btn-save" data-provider="${escapeHtml(provider)}">Save</button>
<button class="btn-delete" data-provider="${escapeHtml(provider)}">Clear</button>
</div>
</div>
`;
})
.join("");
const unmappedHtml = statusData.unmapped_endpoints.length > 0
? `<details>
<summary>${statusData.unmapped_count} unmapped endpoints</summary>
<ul class="unmapped-list">
${statusData.unmapped_endpoints.slice(0, 20).map(ep => `<li>${escapeHtml(ep)}</li>`).join("")}
${statusData.unmapped_count > 20 ? `<li>... and ${statusData.unmapped_count - 20} more</li>` : ""}
</ul>
</details>`
: "<p>All endpoints mapped!</p>";
const modal = createModal("API Key Manager", `
<div class="liberation-status-bar">
<span class="status-item ${statusData.patched ? 'active' : 'inactive'}">
${statusData.patched ? "Active" : "Inactive"} Direct Routing
</span>
<span class="status-item">
${statusData.mapped_patterns} providers mapped
</span>
</div>
<div class="liberation-providers">
<h3>API Keys</h3>
<p class="hint">Enter your API keys below. Keys are saved locally.</p>
${providersHtml}
</div>
<div class="liberation-unmapped">
<h3>Coverage</h3>
${unmappedHtml}
</div>
`, { highlight: highlightProvider });
// Attach event listeners
modal.querySelectorAll<HTMLButtonElement>(".btn-save").forEach(btn => {
btn.onclick = () => saveKey(btn.dataset.provider!);
});
modal.querySelectorAll<HTMLButtonElement>(".btn-delete").forEach(btn => {
btn.onclick = () => deleteKey(btn.dataset.provider!);
});
}
async function saveKey(provider: string): Promise<void> {
const input = document.querySelector<HTMLInputElement>(
`.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<void> {
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<HTMLElement>(".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");
},
});
+18
View File
@@ -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"]
}
+368
View File
@@ -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;
}
+560
View File
@@ -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 = `
<div class="liberation-modal">
<div class="liberation-header">
<h2>${escapeHtml(title)}</h2>
<button class="liberation-close">&times;</button>
</div>
<div class="liberation-content">
${content}
</div>
</div>
`;
// 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
? `<a href="${keyUrl}" target="_blank" rel="noopener" class="get-key-link">Get ${provider.toUpperCase()} API Key &rarr;</a>`
: "";
const reasonLine = opts?.reason === "invalid"
? `<p class="prompt-message" style="color:#f87171;margin-top:-8px;">Your saved key was rejected. Paste a new one below.</p>`
: "";
const detailsLine = opts?.details
? `<p class="hint" style="color:#9ca3af;margin-top:0;">${escapeHtml(opts.details)}</p>`
: "";
const modal = createModal(`Configure ${provider.toUpperCase()} API Key`, `
<div class="liberation-key-prompt">
<p class="prompt-message">
This node requires a <strong>${provider.toUpperCase()}</strong> API key to work.
<br><br>
Enter your API key below to bypass ComfyUI's credit system and use the API directly.
</p>
${reasonLine}
${detailsLine}
${getKeyLink ? `<div class="get-key-section">${getKeyLink}</div>` : ""}
<div class="key-input-group">
<input type="password"
id="liberation-key-input"
class="key-input large"
placeholder="Enter your ${provider.toUpperCase()} API key..."
autofocus>
<button id="liberation-save-key" class="btn-save large">Save & Retry</button>
</div>
<p class="hint">
Your key is stored locally and never sent to ComfyUI servers.
<br>
<a href="#" id="show-all-keys">Manage all API keys...</a>
</p>
</div>
`);
// 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", `<p style="color: #f87171;">Failed to load API key data: ${escapeHtml(error)}</p>`);
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
? `<a href="${keyUrl}" target="_blank" rel="noopener" class="btn-get-key" title="Get API key from ${provider}">Get Key</a>`
: "";
return `
<div class="key-row ${highlightClass}" data-provider="${escapeHtml(provider)}">
<div class="key-info">
<span class="provider-name">${escapeHtml(provider)}</span>
${getKeyLink}
<span class="status ${statusClass}">${statusText}</span>
</div>
<div class="key-actions">
<input type="password"
class="key-input"
placeholder="Enter API key..."
data-provider="${escapeHtml(provider)}">
<button class="btn-save" data-provider="${escapeHtml(provider)}">Save</button>
<button class="btn-delete" data-provider="${escapeHtml(provider)}">Clear</button>
</div>
</div>
`;
})
.join("");
const unmappedHtml = statusData.unmapped_endpoints.length > 0
? `<details>
<summary>${statusData.unmapped_count} unmapped endpoints</summary>
<ul class="unmapped-list">
${statusData.unmapped_endpoints.slice(0, 20).map(ep => `<li>${escapeHtml(ep)}</li>`).join("")}
${statusData.unmapped_count > 20 ? `<li>... and ${statusData.unmapped_count - 20} more</li>` : ""}
</ul>
</details>`
: "<p>All endpoints mapped!</p>";
const modal = createModal("API Key Manager", `
<div class="liberation-status-bar">
<span class="status-item ${statusData.patched ? 'active' : 'inactive'}">
${statusData.patched ? "Active" : "Inactive"} Direct Routing
</span>
<span class="status-item">
${statusData.mapped_patterns} providers mapped
</span>
</div>
<div class="liberation-providers">
<h3>API Keys</h3>
<p class="hint">Enter your API keys below. Keys are saved locally.</p>
${providersHtml}
</div>
<div class="liberation-unmapped">
<h3>Coverage</h3>
${unmappedHtml}
</div>
`, { 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");
},
});