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:
+39
@@ -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/
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
@@ -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
@@ -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())
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
@@ -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()
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
Generated
+29
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
@@ -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
|
||||
@@ -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")
|
||||
@@ -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">×</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 →</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");
|
||||
},
|
||||
});
|
||||
@@ -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"]
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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">×</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 →</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");
|
||||
},
|
||||
});
|
||||
Reference in New Issue
Block a user