Instructions to use saik0s/comfy_backup with libraries, inference providers, notebooks, and local apps. Follow these links to get started.
- Libraries
- llama-cpp-python
How to use saik0s/comfy_backup with llama-cpp-python:
# !pip install llama-cpp-python from llama_cpp import Llama llm = Llama.from_pretrained( repo_id="saik0s/comfy_backup", filename="models/text_encoders/Qwen3VL-8B-Uncensored-HauhauCS-Aggressive-Q8_0.gguf", )
llm.create_chat_completion( messages = "No input example has been defined for this model task." )
- Notebooks
- Google Colab
- Kaggle
- Local Apps Settings
- llama.cpp
How to use saik0s/comfy_backup with llama.cpp:
Install (macOS, Linux)
curl -LsSf https://llama.app/install.sh | sh # Start a local OpenAI-compatible server with a web UI: llama serve -hf saik0s/comfy_backup:Q8_0 # Run inference directly in the terminal: llama cli -hf saik0s/comfy_backup:Q8_0
Install from WinGet (Windows)
winget install llama.cpp # Start a local OpenAI-compatible server with a web UI: llama serve -hf saik0s/comfy_backup:Q8_0 # Run inference directly in the terminal: llama cli -hf saik0s/comfy_backup:Q8_0
Use pre-built binary
# Download pre-built binary from: # https://github.com/ggerganov/llama.cpp/releases # Start a local OpenAI-compatible server with a web UI: ./llama-server -hf saik0s/comfy_backup:Q8_0 # Run inference directly in the terminal: ./llama-cli -hf saik0s/comfy_backup:Q8_0
Build from source code
git clone https://github.com/ggerganov/llama.cpp.git cd llama.cpp cmake -B build cmake --build build -j --target llama-server llama-cli # Start a local OpenAI-compatible server with a web UI: ./build/bin/llama-server -hf saik0s/comfy_backup:Q8_0 # Run inference directly in the terminal: ./build/bin/llama-cli -hf saik0s/comfy_backup:Q8_0
Use Docker
docker model run hf.co/saik0s/comfy_backup:Q8_0
- LM Studio
- Jan
- Ollama
How to use saik0s/comfy_backup with Ollama:
ollama run hf.co/saik0s/comfy_backup:Q8_0
- Unsloth Studio
How to use saik0s/comfy_backup with Unsloth Studio:
Install Unsloth Studio (macOS, Linux, WSL)
curl -fsSL https://unsloth.ai/install.sh | sh # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for saik0s/comfy_backup to start chatting
Install Unsloth Studio (Windows)
irm https://unsloth.ai/install.ps1 | iex # Run unsloth studio unsloth studio -H 0.0.0.0 -p 8888 # Then open http://localhost:8888 in your browser # Search for saik0s/comfy_backup to start chatting
Using HuggingFace Spaces for Unsloth
# No setup required # Open https://huggingface.co/spaces/unsloth/studio in your browser # Search for saik0s/comfy_backup to start chatting
- Pi
How to use saik0s/comfy_backup with Pi:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf saik0s/comfy_backup:Q8_0
Configure the model in Pi
# Install Pi: npm install -g @mariozechner/pi-coding-agent # Add to ~/.pi/agent/models.json: { "providers": { "llama-cpp": { "baseUrl": "http://localhost:8080/v1", "api": "openai-completions", "apiKey": "none", "models": [ { "id": "saik0s/comfy_backup:Q8_0" } ] } } }Run Pi
# Start Pi in your project directory: pi
- Hermes Agent new
How to use saik0s/comfy_backup with Hermes Agent:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf saik0s/comfy_backup:Q8_0
Configure Hermes
# Install Hermes: curl -fsSL https://hermes-agent.nousresearch.com/install.sh | bash hermes setup # Point Hermes at the local server: hermes config set model.provider custom hermes config set model.base_url http://127.0.0.1:8080/v1 hermes config set model.default saik0s/comfy_backup:Q8_0
Run Hermes
hermes
- Atomic Chat new
- OpenClaw new
How to use saik0s/comfy_backup with OpenClaw:
Start the llama.cpp server
# Install llama.cpp: brew install llama.cpp # Start a local OpenAI-compatible server: llama serve -hf saik0s/comfy_backup:Q8_0
Configure OpenClaw
# Install OpenClaw: npm install -g openclaw@latest # Register the local server and set it as the default model: openclaw onboard --non-interactive --mode local \ --auth-choice custom-api-key \ --custom-base-url http://127.0.0.1:8080/v1 \ --custom-model-id "saik0s/comfy_backup:Q8_0" \ --custom-provider-id llama-cpp \ --custom-compatibility openai \ --custom-text-input \ --accept-risk \ --skip-health
Run OpenClaw
openclaw agent --local --agent main --message "Hello from Hugging Face"
- Docker Model Runner
How to use saik0s/comfy_backup with Docker Model Runner:
docker model run hf.co/saik0s/comfy_backup:Q8_0
- Lemonade
How to use saik0s/comfy_backup with Lemonade:
Pull the model
# Download Lemonade from https://lemonade-server.ai/ lemonade pull saik0s/comfy_backup:Q8_0
Run and chat with the model
lemonade run user.comfy_backup-Q8_0
List all available models
lemonade list
Upload folder using huggingface_hub
Browse files- custom_nodes/openrouter_node/.github/workflows/publish.yml +28 -0
- custom_nodes/openrouter_node/.gitignore +47 -0
- custom_nodes/openrouter_node/LICENSE +21 -0
- custom_nodes/openrouter_node/README.md +259 -0
- custom_nodes/openrouter_node/__init__.py +5 -0
- custom_nodes/openrouter_node/chat_manager.py +309 -0
- custom_nodes/openrouter_node/examples/chat_mode_example.json +130 -0
- custom_nodes/openrouter_node/manage_chats.py +198 -0
- custom_nodes/openrouter_node/nano_banana_example.jpeg +3 -0
- custom_nodes/openrouter_node/node.py +748 -0
- custom_nodes/openrouter_node/openrouter_api_key.json.example +3 -0
- custom_nodes/openrouter_node/openrouter_node_example.png +3 -0
- custom_nodes/openrouter_node/pyproject.toml +15 -0
- custom_nodes/openrouter_node/requirements.txt +2 -0
- custom_nodes/openrouter_node/tests/__init__.py +1 -0
- custom_nodes/openrouter_node/tests/test_request_timeout.py +204 -0
- custom_nodes/openrouter_node/web/openrouter_dynamic_inputs.js +138 -0
custom_nodes/openrouter_node/.github/workflows/publish.yml
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
name: Publish to Comfy registry
|
| 2 |
+
on:
|
| 3 |
+
workflow_dispatch:
|
| 4 |
+
push:
|
| 5 |
+
branches:
|
| 6 |
+
- main
|
| 7 |
+
- master
|
| 8 |
+
paths:
|
| 9 |
+
- "pyproject.toml"
|
| 10 |
+
|
| 11 |
+
permissions:
|
| 12 |
+
issues: write
|
| 13 |
+
|
| 14 |
+
jobs:
|
| 15 |
+
publish-node:
|
| 16 |
+
name: Publish Custom Node to registry
|
| 17 |
+
runs-on: ubuntu-latest
|
| 18 |
+
if: ${{ github.repository_owner == 'gabe-init' }}
|
| 19 |
+
steps:
|
| 20 |
+
- name: Check out code
|
| 21 |
+
uses: actions/checkout@v4
|
| 22 |
+
with:
|
| 23 |
+
submodules: true
|
| 24 |
+
- name: Publish Custom Node
|
| 25 |
+
uses: Comfy-Org/publish-node-action@v1
|
| 26 |
+
with:
|
| 27 |
+
## Add your own personal access token to your Github Repository secrets and reference it here.
|
| 28 |
+
personal_access_token: ${{ secrets.REGISTRY_ACCESS_TOKEN }}
|
custom_nodes/openrouter_node/.gitignore
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Python
|
| 2 |
+
__pycache__/
|
| 3 |
+
*.py[cod]
|
| 4 |
+
*$py.class
|
| 5 |
+
*.so
|
| 6 |
+
.Python
|
| 7 |
+
build/
|
| 8 |
+
develop-eggs/
|
| 9 |
+
dist/
|
| 10 |
+
downloads/
|
| 11 |
+
eggs/
|
| 12 |
+
.eggs/
|
| 13 |
+
lib/
|
| 14 |
+
lib64/
|
| 15 |
+
parts/
|
| 16 |
+
sdist/
|
| 17 |
+
var/
|
| 18 |
+
wheels/
|
| 19 |
+
*.egg-info/
|
| 20 |
+
.installed.cfg
|
| 21 |
+
*.egg
|
| 22 |
+
|
| 23 |
+
# Environment
|
| 24 |
+
.env
|
| 25 |
+
.venv
|
| 26 |
+
env/
|
| 27 |
+
venv/
|
| 28 |
+
ENV/
|
| 29 |
+
|
| 30 |
+
# IDE
|
| 31 |
+
.idea/
|
| 32 |
+
.vscode/
|
| 33 |
+
*.swp
|
| 34 |
+
*.swo
|
| 35 |
+
*~
|
| 36 |
+
|
| 37 |
+
# OS
|
| 38 |
+
.DS_Store
|
| 39 |
+
Thumbs.db
|
| 40 |
+
*.Zone.Identifier
|
| 41 |
+
|
| 42 |
+
# Secrets
|
| 43 |
+
api_keys.json
|
| 44 |
+
secrets.json
|
| 45 |
+
*.key
|
| 46 |
+
openrouter_api_key.json
|
| 47 |
+
chats/
|
custom_nodes/openrouter_node/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
MIT License
|
| 2 |
+
|
| 3 |
+
Copyright (c) 2024 gabe-init
|
| 4 |
+
|
| 5 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 6 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 7 |
+
in the Software without restriction, including without limitation the rights
|
| 8 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 9 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 10 |
+
furnished to do so, subject to the following conditions:
|
| 11 |
+
|
| 12 |
+
The above copyright notice and this permission notice shall be included in all
|
| 13 |
+
copies or substantial portions of the Software.
|
| 14 |
+
|
| 15 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 16 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 17 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 18 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 19 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 20 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 21 |
+
SOFTWARE.
|
custom_nodes/openrouter_node/README.md
ADDED
|
@@ -0,0 +1,259 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# ComfyUI OpenRouter Node
|
| 2 |
+
|
| 3 |
+
A custom node for ComfyUI that allows you to interact with OpenRouter's API, providing access to a wide range of models.
|
| 4 |
+
|
| 5 |
+
## Updates
|
| 6 |
+
|
| 7 |
+
### 5/18/2026
|
| 8 |
+
|
| 9 |
+
Reasoning effort setting added. Make sure the model supports reasoning when using
|
| 10 |
+
|
| 11 |
+
### 5/9/2026 - Secure API Key Loading
|
| 12 |
+
|
| 13 |
+
You can now load your API key from a JSON config or env var instead of typing it into the node, which avoids leaking it in workflow metadata when sharing images.
|
| 14 |
+
|
| 15 |
+
- **JSON config:** put `{"api_key": "your_key_here"}` in `openrouter_api_key.json` in the node directory, leave the field blank
|
| 16 |
+
- **Env var:** set `LLM_KEY`, leave the field blank
|
| 17 |
+
- **UI field:** still works if you don't share workflows
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
### 4/5/2026 - Seed, Resolution, Aspect Ratio, Temperature Fix
|
| 21 |
+
- Added **seed** input
|
| 22 |
+
- Added **image_resolution** dropdown (1K, 2K, 4K)
|
| 23 |
+
- Added **aspect_ratio** dropdown with all supported ratios, including Nano Banana 2 only extended ratios (1:4, 4:1, 1:8, 8:1)
|
| 24 |
+
- Fixed temperature slider
|
| 25 |
+
- 0.5K resolution (Nano Banana 2 only) not added - currently broken on OpenRouter's end, see [this thread](https://discord.com/channels/1091220969173028894/1484099048100073484/1484099048100073484) in the OpenRouter Discord
|
| 26 |
+
|
| 27 |
+

|
| 28 |
+
|
| 29 |
+

|
| 30 |
+
|
| 31 |
+
Multiple image inputs are supported. Make sure the model you are using supports multiple images to be sent at once. Thanks **@wTechArtist** for the idea!
|
| 32 |
+

|
| 33 |
+
|
| 34 |
+
### 9/5/2025 - Added image support for nano-banana and future image models
|
| 35 |
+
|
| 36 |
+
### 6/12/2025 - Chat Mode
|
| 37 |
+
Added a new Chat Mode feature that lets you store context to enable conversations with LLMs. When you enable chat mode, the node remembers your conversation history and maintains context between messages. Your chats are automatically saved in timestamped folders, so you can pick up where you left off if you come back within an hour. After that, it'll start a fresh conversation. Each chat session is stored as a JSON file with a friendly name based on your first message. Just toggle "chat_mode"
|
| 38 |
+
|
| 39 |
+
## Features
|
| 40 |
+
|
| 41 |
+
- Access to all models available on OpenRouter
|
| 42 |
+
- Support for multiple image inputs (up to 10 images)
|
| 43 |
+
- **NEW: Image generation support** - Generate images with models like google/gemini-2.5-flash-image-preview (Nano-Banana)
|
| 44 |
+
- Dynamic image input visibility - additional inputs appear as you connect images
|
| 45 |
+
- PDF support with multiple OCR engine options
|
| 46 |
+
- Web search capability with `:online` modifier
|
| 47 |
+
- Cheapest provider routing with `:floor` modifier
|
| 48 |
+
- Fastest provider routing with `:nitro` modifier
|
| 49 |
+
- Detailed statistics on token usage and generation speed
|
| 50 |
+
- Real-time OpenRouter account balance display
|
| 51 |
+
- **Chat Mode** - Maintain conversation context across multiple messages with automatic session management
|
| 52 |
+
|
| 53 |
+
## Installation
|
| 54 |
+
|
| 55 |
+
1. Clone this repository into your ComfyUI custom_nodes folder:
|
| 56 |
+
```bash
|
| 57 |
+
cd ComfyUI/custom_nodes
|
| 58 |
+
git clone https://github.com/gabe-init/ComfyUI-Openrouter_node
|
| 59 |
+
```
|
| 60 |
+
|
| 61 |
+
2. Install the required dependencies:
|
| 62 |
+
```bash
|
| 63 |
+
pip install -r requirements.txt
|
| 64 |
+
```
|
| 65 |
+
|
| 66 |
+
3. Restart ComfyUI
|
| 67 |
+
|
| 68 |
+
## Usage
|
| 69 |
+
|
| 70 |
+
The OpenRouter node provides a simple interface to interact with various LLMs through the OpenRouter API.
|
| 71 |
+
|
| 72 |
+
### API Key Security
|
| 73 |
+
|
| 74 |
+
> [!WARNING]
|
| 75 |
+
> Entering your API key directly into the node's input field in ComfyUI will embed it in the **workflow metadata** of every image you save. This is a security risk if you share your images.
|
| 76 |
+
|
| 77 |
+
To keep your API key secure, use one of the following methods:
|
| 78 |
+
|
| 79 |
+
1. **JSON Config File (Recommended)**:
|
| 80 |
+
- Create a file named `openrouter_api_key.json` in the node directory.
|
| 81 |
+
- Add your key like this: `{"api_key": "your_key_here"}`.
|
| 82 |
+
- Leave the **api_key** field blank in the ComfyUI interface.
|
| 83 |
+
|
| 84 |
+
2. **Environment Variable**:
|
| 85 |
+
- Set an environment variable named `LLM_KEY` on your system.
|
| 86 |
+
- The node will automatically pick it up if the UI field is empty.
|
| 87 |
+
|
| 88 |
+
> [!NOTE]
|
| 89 |
+
> If you've ever typed your key into the **api_key** field, clear it before saving the workflow — the value is stored in the workflow JSON. On Linux/macOS, restrict the JSON config with `chmod 600 openrouter_api_key.json` so only your user can read it.
|
| 90 |
+
|
| 91 |
+
### Inputs
|
| 92 |
+
|
| 93 |
+
#### Required Inputs:
|
| 94 |
+
|
| 95 |
+
- **api_key**: Your OpenRouter API key. Can be left blank if provided via `openrouter_api_key.json` or `LLM_KEY` environment variable.
|
| 96 |
+
- **system_prompt**: The system prompt that sets the behavior of the LLM.
|
| 97 |
+
- **user_message_box**: The user message to send to the LLM.
|
| 98 |
+
- **model**: The model to use for generation. The node automatically fetches the list of available models from OpenRouter.
|
| 99 |
+
- **web_search**: Enable web search capability by appending `:online` to the model ID. This costs $4 per 1000 queries and automatically uses your openrouter balance.
|
| 100 |
+
- **cheapest**: Route to the cheapest provider by appending `:floor` to the model ID (enabled by default).
|
| 101 |
+
- **fastest**: Route to the fastest provider by appending `:nitro` to the model ID (disabled by default).
|
| 102 |
+
- **temperature**: Controls the randomness of the model's output (0.0 to 2.0).
|
| 103 |
+
- **reasoning_effort**: Controls OpenRouter's unified reasoning parameter. `auto` sends no override and lets OpenRouter/model defaults apply. `none` explicitly disables reasoning. `minimal`, `low`, `medium`, `high`, and `xhigh` request increasing reasoning effort where the selected model supports it.
|
| 104 |
+
- **chat_mode**: Enable conversation mode to maintain context across messages (disabled by default).
|
| 105 |
+
- **request_timeout**: Maximum time, in seconds, to wait for the main OpenRouter completion request before returning an error (default: 120).
|
| 106 |
+
|
| 107 |
+
> [!NOTE]
|
| 108 |
+
> OpenRouter normalizes reasoning across providers, but behavior still depends on the selected model. Some providers map effort levels to token budgets, Gemini 3 maps effort to Google's thinking levels, `xhigh` may be mapped down when unsupported, and some reasoning models do not return visible reasoning tokens.
|
| 109 |
+
|
| 110 |
+
#### Optional Inputs:
|
| 111 |
+
|
| 112 |
+
- **image_1** through **image_10**: Multiple image inputs for multimodal models. The first image input (image_1) is always visible. Additional image inputs automatically appear as you connect images (up to 10 total).
|
| 113 |
+
- **pdf_data**: PDF document input for models that support document understanding.
|
| 114 |
+
- **pdf_engine**: Choose between "auto", "mistral-ocr", or "pdf-text" for PDF processing.
|
| 115 |
+
- **user_message_input**: Alternative input for the user message, useful for connecting to other nodes.
|
| 116 |
+
|
| 117 |
+
### Outputs:
|
| 118 |
+
|
| 119 |
+
- **Output**: The text response from the LLM.
|
| 120 |
+
- **image**: An image tensor if the response contains a generated image, otherwise returns an empty tensor.
|
| 121 |
+
- **Stats**: A string detailing tokens per second, input tokens, output tokens, temperature, and the model used.
|
| 122 |
+
- **Credits**: A string showing your remaining OpenRouter account balance (e.g., "Remaining: $9.792").
|
| 123 |
+
|
| 124 |
+
Note: To display the output text in ComfyUI, you can use the ShowText nodes from [ComfyUI-Custom-Scripts](https://github.com/pythongosssss/ComfyUI-Custom-Scripts), but any text display node will work.
|
| 125 |
+
|
| 126 |
+
## Examples
|
| 127 |
+
|
| 128 |
+
### Basic Text Generation
|
| 129 |
+
|
| 130 |
+
1. Add the OpenRouter node to your workflow
|
| 131 |
+
2. Provide your API key (see [API Key Security](#api-key-security))
|
| 132 |
+
3. Set a system prompt (e.g., "You are a helpful assistant.")
|
| 133 |
+
4. Enter a user message (e.g., "Explain quantum computing in simple terms.")
|
| 134 |
+
5. Select a model (e.g., "openai/gpt-4")
|
| 135 |
+
6. Run the workflow
|
| 136 |
+
|
| 137 |
+
### Image Understanding
|
| 138 |
+
|
| 139 |
+
1. Add the OpenRouter node to your workflow
|
| 140 |
+
2. Connect an image output from another node to the "image_1" input
|
| 141 |
+
3. Provide your API key (see [API Key Security](#api-key-security))
|
| 142 |
+
4. Set a system prompt (e.g., "You are a helpful assistant.")
|
| 143 |
+
5. Enter a user message (e.g., "Describe this image in detail.")
|
| 144 |
+
6. Select a multimodal model (e.g., "openai/gpt-4-vision" or "anthropic/claude-3-opus-20240229")
|
| 145 |
+
7. Run the workflow
|
| 146 |
+
|
| 147 |
+
### Multiple Image Analysis
|
| 148 |
+
|
| 149 |
+
1. Connect your first image to "image_1"
|
| 150 |
+
2. As soon as you connect it, "image_2" will automatically appear
|
| 151 |
+
3. Connect additional images as needed (up to 6 total)
|
| 152 |
+
4. Unused image inputs will automatically hide when disconnected
|
| 153 |
+
5. Enter a prompt that references multiple images (e.g., "Compare these images and describe the differences.")
|
| 154 |
+
6. Select a multimodal model that supports multiple images
|
| 155 |
+
7. Run the workflow
|
| 156 |
+
|
| 157 |
+
**Note**: The user is responsible for checking if their selected model supports multiple images. Most modern multimodal models (4o, Gemini Flash, etc.) support multiple images in a single request.
|
| 158 |
+
|
| 159 |
+
### Image Generation
|
| 160 |
+
|
| 161 |
+
1. Add the OpenRouter node to your workflow
|
| 162 |
+
2. Provide your API key (see [API Key Security](#api-key-security))
|
| 163 |
+
3. Set a system prompt (e.g., "You are a helpful assistant.")
|
| 164 |
+
4. Enter a user message with generation keywords (e.g., "Generate a beautiful sunset over mountains", "Create an image of a futuristic city", "Draw a cat wearing a hat")
|
| 165 |
+
5. Select an image-capable model (e.g., "google/gemini-2.5-flash-image-preview" - also known as Nano-Banana)
|
| 166 |
+
6. Run the workflow
|
| 167 |
+
7. The generated image will appear in the "image" output, which you can connect to preview nodes or other image processing nodes
|
| 168 |
+
|
| 169 |
+
**Note**: The node automatically detects image generation requests based on keywords like "generate", "create", "draw", "make", "produce", "design", "render", "image of", "picture of", "photo of". For image generation, use models that support image output modalities.
|
| 170 |
+
|
| 171 |
+
### Chat Mode
|
| 172 |
+
|
| 173 |
+
The Chat Mode feature allows you to maintain conversation context across multiple messages, enabling more natural and coherent conversations with the LLM.
|
| 174 |
+
|
| 175 |
+
#### How Chat Mode Works:
|
| 176 |
+
|
| 177 |
+
1. **Enable Chat Mode**: Toggle the "chat_mode" option to True
|
| 178 |
+
2. **Automatic Session Management**:
|
| 179 |
+
- Sessions are automatically created when you start a conversation
|
| 180 |
+
- If you send another message within 1 hour, it continues the same session
|
| 181 |
+
- After 1 hour of inactivity, a new session is created
|
| 182 |
+
3. **Session Storage**:
|
| 183 |
+
- Conversations are stored in a `chats` folder within the node directory
|
| 184 |
+
- Each session is saved with a timestamp and the first 5 words of your initial message
|
| 185 |
+
- Format: `session_YYYYMMDD_HHMMSS_first_five_words`
|
| 186 |
+
4. **Context Preservation**: The entire conversation history is sent with each request, allowing the model to maintain context
|
| 187 |
+
|
| 188 |
+
#### Chat Mode Example:
|
| 189 |
+
|
| 190 |
+
1. Enable chat_mode in the node
|
| 191 |
+
2. First message: "Hello, my name is Alice"
|
| 192 |
+
3. Response: "Hello Alice! Nice to meet you. How can I help you today?"
|
| 193 |
+
4. Second message: "What's my name?"
|
| 194 |
+
5. Response: "Your name is Alice, as you mentioned earlier."
|
| 195 |
+
|
| 196 |
+
The conversation history is automatically managed and persisted between runs.
|
| 197 |
+
|
| 198 |
+
#### Managing Chat Sessions:
|
| 199 |
+
|
| 200 |
+
A utility script `manage_chats.py` is included to help you manage your chat sessions:
|
| 201 |
+
|
| 202 |
+
```bash
|
| 203 |
+
# List all chat sessions
|
| 204 |
+
python manage_chats.py list
|
| 205 |
+
|
| 206 |
+
# View a specific session
|
| 207 |
+
python manage_chats.py view session_20241206_143022_hello_how_are_you_today
|
| 208 |
+
|
| 209 |
+
# Export a session to different formats (json, txt, md)
|
| 210 |
+
python manage_chats.py export session_20241206_143022_hello_how_are_you_today -f md -o my_chat.md
|
| 211 |
+
|
| 212 |
+
# Clean up sessions older than 30 days
|
| 213 |
+
python manage_chats.py clean -d 30
|
| 214 |
+
```
|
| 215 |
+
|
| 216 |
+
### Routing Options
|
| 217 |
+
|
| 218 |
+
- For cost-effective responses, enable the "cheapest" option (on by default)
|
| 219 |
+
- For faster responses, disable "cheapest" and enable "fastest"
|
| 220 |
+
- For web search capability, enable "web_search"
|
| 221 |
+
|
| 222 |
+
## Troubleshooting
|
| 223 |
+
|
| 224 |
+
- **Model list not loading**: Check your internet connection and OpenRouter API key.
|
| 225 |
+
- **Error in response**: Check the error message in the output. It might be due to an invalid API key, model unavailability, or other API issues.
|
| 226 |
+
- **Slow responses**: Try using the `:nitro` modifier by enabling the "fastest" option.
|
| 227 |
+
- **Token counting issues**: The node uses tiktoken for accurate token counting, but falls back to an estimation method if there's an issue.
|
| 228 |
+
|
| 229 |
+
## License
|
| 230 |
+
|
| 231 |
+
MIT License
|
| 232 |
+
|
| 233 |
+
Copyright (c) 2024
|
| 234 |
+
|
| 235 |
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
| 236 |
+
of this software and associated documentation files (the "Software"), to deal
|
| 237 |
+
in the Software without restriction, including without limitation the rights
|
| 238 |
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
| 239 |
+
copies of the Software, and to permit persons to whom the Software is
|
| 240 |
+
furnished to do so, subject to the following conditions:
|
| 241 |
+
|
| 242 |
+
The above copyright notice and this permission notice shall be included in all
|
| 243 |
+
copies or substantial portions of the Software.
|
| 244 |
+
|
| 245 |
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
| 246 |
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
| 247 |
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
| 248 |
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
| 249 |
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
| 250 |
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
| 251 |
+
SOFTWARE.
|
| 252 |
+
|
| 253 |
+
## Credits
|
| 254 |
+
|
| 255 |
+
- [OpenRouter](https://openrouter.ai/)
|
| 256 |
+
- [ComfyUI](https://github.com/comfyanonymous/ComfyUI)
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
|
custom_nodes/openrouter_node/__init__.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .node import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS
|
| 2 |
+
|
| 3 |
+
WEB_DIRECTORY = "./web"
|
| 4 |
+
__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS", "WEB_DIRECTORY"]
|
| 5 |
+
|
custom_nodes/openrouter_node/chat_manager.py
ADDED
|
@@ -0,0 +1,309 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import json
|
| 3 |
+
import datetime
|
| 4 |
+
import re
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from typing import List, Dict, Optional, Tuple
|
| 7 |
+
import time
|
| 8 |
+
|
| 9 |
+
class ChatSessionManager:
|
| 10 |
+
"""
|
| 11 |
+
Manages chat sessions for the OpenRouter node.
|
| 12 |
+
Handles creating, loading, and updating chat conversations with automatic session management.
|
| 13 |
+
"""
|
| 14 |
+
|
| 15 |
+
def __init__(self, base_path: str = None):
|
| 16 |
+
"""
|
| 17 |
+
Initialize the chat session manager.
|
| 18 |
+
|
| 19 |
+
Args:
|
| 20 |
+
base_path: Base directory for storing chats. Defaults to node's directory + /chats
|
| 21 |
+
"""
|
| 22 |
+
if base_path is None:
|
| 23 |
+
base_path = os.path.join(os.path.dirname(__file__), "chats")
|
| 24 |
+
|
| 25 |
+
self.base_path = Path(base_path)
|
| 26 |
+
self.base_path.mkdir(exist_ok=True)
|
| 27 |
+
self.current_session_path = None
|
| 28 |
+
self.session_timeout_hours = 1 # Sessions expire after 1 hour of inactivity
|
| 29 |
+
|
| 30 |
+
def _sanitize_filename(self, text: str, max_length: int = 50) -> str:
|
| 31 |
+
"""
|
| 32 |
+
Sanitize text for use in filenames.
|
| 33 |
+
|
| 34 |
+
Args:
|
| 35 |
+
text: Text to sanitize
|
| 36 |
+
max_length: Maximum length of the sanitized text
|
| 37 |
+
|
| 38 |
+
Returns:
|
| 39 |
+
Sanitized text safe for filenames
|
| 40 |
+
"""
|
| 41 |
+
# Get first 5 words or max_length characters, whichever is shorter
|
| 42 |
+
words = text.split()[:5]
|
| 43 |
+
text = " ".join(words)
|
| 44 |
+
|
| 45 |
+
# Remove special characters
|
| 46 |
+
text = re.sub(r'[^\w\s-]', '', text)
|
| 47 |
+
text = re.sub(r'[-\s]+', '_', text)
|
| 48 |
+
|
| 49 |
+
# Truncate to max length
|
| 50 |
+
if len(text) > max_length:
|
| 51 |
+
text = text[:max_length].rstrip('_')
|
| 52 |
+
|
| 53 |
+
return text.lower()
|
| 54 |
+
|
| 55 |
+
def _get_timestamp(self) -> str:
|
| 56 |
+
"""Get current timestamp in format YYYYMMDD_HHMMSS"""
|
| 57 |
+
return datetime.datetime.now().strftime("%Y%m%d_%H%M%S")
|
| 58 |
+
|
| 59 |
+
def _get_iso_timestamp(self) -> str:
|
| 60 |
+
"""Get current timestamp in ISO format"""
|
| 61 |
+
return datetime.datetime.now().isoformat()
|
| 62 |
+
|
| 63 |
+
def _find_active_session(self) -> Optional[Path]:
|
| 64 |
+
"""
|
| 65 |
+
Find an active session within the timeout period.
|
| 66 |
+
|
| 67 |
+
Returns:
|
| 68 |
+
Path to active session directory or None
|
| 69 |
+
"""
|
| 70 |
+
if not self.base_path.exists():
|
| 71 |
+
return None
|
| 72 |
+
|
| 73 |
+
current_time = time.time()
|
| 74 |
+
|
| 75 |
+
# Get all session directories
|
| 76 |
+
sessions = [d for d in self.base_path.iterdir() if d.is_dir()]
|
| 77 |
+
|
| 78 |
+
# Sort by modification time (most recent first)
|
| 79 |
+
sessions.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
| 80 |
+
|
| 81 |
+
for session_dir in sessions:
|
| 82 |
+
conversation_file = session_dir / "conversation.json"
|
| 83 |
+
if conversation_file.exists():
|
| 84 |
+
# Check if session is within timeout period
|
| 85 |
+
last_modified = conversation_file.stat().st_mtime
|
| 86 |
+
hours_elapsed = (current_time - last_modified) / 3600
|
| 87 |
+
|
| 88 |
+
if hours_elapsed <= self.session_timeout_hours:
|
| 89 |
+
return session_dir
|
| 90 |
+
|
| 91 |
+
return None
|
| 92 |
+
|
| 93 |
+
def _create_new_session(self, first_message: str) -> Path:
|
| 94 |
+
"""
|
| 95 |
+
Create a new chat session directory.
|
| 96 |
+
|
| 97 |
+
Args:
|
| 98 |
+
first_message: The first user message to use in the session name
|
| 99 |
+
|
| 100 |
+
Returns:
|
| 101 |
+
Path to the new session directory
|
| 102 |
+
"""
|
| 103 |
+
timestamp = self._get_timestamp()
|
| 104 |
+
sanitized_message = self._sanitize_filename(first_message)
|
| 105 |
+
|
| 106 |
+
# Create session directory name
|
| 107 |
+
session_name = f"session_{timestamp}_{sanitized_message}"
|
| 108 |
+
session_path = self.base_path / session_name
|
| 109 |
+
|
| 110 |
+
# Handle potential naming conflicts
|
| 111 |
+
counter = 1
|
| 112 |
+
while session_path.exists():
|
| 113 |
+
session_path = self.base_path / f"{session_name}_{counter}"
|
| 114 |
+
counter += 1
|
| 115 |
+
|
| 116 |
+
session_path.mkdir(exist_ok=True)
|
| 117 |
+
return session_path
|
| 118 |
+
|
| 119 |
+
def get_or_create_session(self, user_message: str, system_prompt: str) -> Tuple[Path, List[Dict]]:
|
| 120 |
+
"""
|
| 121 |
+
Get an active session or create a new one.
|
| 122 |
+
|
| 123 |
+
Args:
|
| 124 |
+
user_message: The current user message
|
| 125 |
+
system_prompt: The system prompt to use
|
| 126 |
+
|
| 127 |
+
Returns:
|
| 128 |
+
Tuple of (session_path, message_history)
|
| 129 |
+
"""
|
| 130 |
+
# Try to find an active session
|
| 131 |
+
active_session = self._find_active_session()
|
| 132 |
+
|
| 133 |
+
if active_session:
|
| 134 |
+
self.current_session_path = active_session
|
| 135 |
+
messages = self.load_conversation(active_session)
|
| 136 |
+
return active_session, messages
|
| 137 |
+
else:
|
| 138 |
+
# Create new session
|
| 139 |
+
new_session = self._create_new_session(user_message)
|
| 140 |
+
self.current_session_path = new_session
|
| 141 |
+
|
| 142 |
+
# Initialize with system prompt
|
| 143 |
+
messages = [{"role": "system", "content": system_prompt}]
|
| 144 |
+
|
| 145 |
+
# Create initial conversation file
|
| 146 |
+
conversation_data = {
|
| 147 |
+
"session_id": new_session.name,
|
| 148 |
+
"created_at": self._get_iso_timestamp(),
|
| 149 |
+
"last_updated": self._get_iso_timestamp(),
|
| 150 |
+
"messages": messages
|
| 151 |
+
}
|
| 152 |
+
|
| 153 |
+
conversation_file = new_session / "conversation.json"
|
| 154 |
+
with open(conversation_file, 'w', encoding='utf-8') as f:
|
| 155 |
+
json.dump(conversation_data, f, indent=2, ensure_ascii=False)
|
| 156 |
+
|
| 157 |
+
return new_session, messages
|
| 158 |
+
|
| 159 |
+
def load_conversation(self, session_path: Path) -> List[Dict]:
|
| 160 |
+
"""
|
| 161 |
+
Load conversation history from a session.
|
| 162 |
+
|
| 163 |
+
Args:
|
| 164 |
+
session_path: Path to the session directory
|
| 165 |
+
|
| 166 |
+
Returns:
|
| 167 |
+
List of messages in the conversation
|
| 168 |
+
"""
|
| 169 |
+
conversation_file = session_path / "conversation.json"
|
| 170 |
+
|
| 171 |
+
if not conversation_file.exists():
|
| 172 |
+
return []
|
| 173 |
+
|
| 174 |
+
try:
|
| 175 |
+
with open(conversation_file, 'r', encoding='utf-8') as f:
|
| 176 |
+
data = json.load(f)
|
| 177 |
+
return data.get("messages", [])
|
| 178 |
+
except (json.JSONDecodeError, IOError) as e:
|
| 179 |
+
print(f"Error loading conversation from {conversation_file}: {e}")
|
| 180 |
+
return []
|
| 181 |
+
|
| 182 |
+
def save_conversation(self, session_path: Path, messages: List[Dict]):
|
| 183 |
+
"""
|
| 184 |
+
Save conversation history to a session.
|
| 185 |
+
|
| 186 |
+
Args:
|
| 187 |
+
session_path: Path to the session directory
|
| 188 |
+
messages: List of messages to save
|
| 189 |
+
"""
|
| 190 |
+
conversation_file = session_path / "conversation.json"
|
| 191 |
+
|
| 192 |
+
# Load existing data to preserve metadata
|
| 193 |
+
existing_data = {}
|
| 194 |
+
if conversation_file.exists():
|
| 195 |
+
try:
|
| 196 |
+
with open(conversation_file, 'r', encoding='utf-8') as f:
|
| 197 |
+
existing_data = json.load(f)
|
| 198 |
+
except (json.JSONDecodeError, IOError):
|
| 199 |
+
pass
|
| 200 |
+
|
| 201 |
+
# Update conversation data
|
| 202 |
+
conversation_data = {
|
| 203 |
+
"session_id": existing_data.get("session_id", session_path.name),
|
| 204 |
+
"created_at": existing_data.get("created_at", self._get_iso_timestamp()),
|
| 205 |
+
"last_updated": self._get_iso_timestamp(),
|
| 206 |
+
"messages": messages
|
| 207 |
+
}
|
| 208 |
+
|
| 209 |
+
# Save to file
|
| 210 |
+
try:
|
| 211 |
+
with open(conversation_file, 'w', encoding='utf-8') as f:
|
| 212 |
+
json.dump(conversation_data, f, indent=2, ensure_ascii=False)
|
| 213 |
+
except IOError as e:
|
| 214 |
+
print(f"Error saving conversation to {conversation_file}: {e}")
|
| 215 |
+
|
| 216 |
+
def append_message(self, role: str, content: str, session_path: Optional[Path] = None):
|
| 217 |
+
"""
|
| 218 |
+
Append a message to the current or specified session.
|
| 219 |
+
|
| 220 |
+
Args:
|
| 221 |
+
role: Message role ('user', 'assistant', or 'system')
|
| 222 |
+
content: Message content
|
| 223 |
+
session_path: Optional specific session path, uses current if not provided
|
| 224 |
+
"""
|
| 225 |
+
if session_path is None:
|
| 226 |
+
session_path = self.current_session_path
|
| 227 |
+
|
| 228 |
+
if session_path is None:
|
| 229 |
+
raise ValueError("No active session to append message to")
|
| 230 |
+
|
| 231 |
+
# Load existing messages
|
| 232 |
+
messages = self.load_conversation(session_path)
|
| 233 |
+
|
| 234 |
+
# Append new message
|
| 235 |
+
messages.append({
|
| 236 |
+
"role": role,
|
| 237 |
+
"content": content,
|
| 238 |
+
"timestamp": self._get_iso_timestamp()
|
| 239 |
+
})
|
| 240 |
+
|
| 241 |
+
# Save updated conversation
|
| 242 |
+
self.save_conversation(session_path, messages)
|
| 243 |
+
|
| 244 |
+
def get_recent_sessions(self, limit: int = 10) -> List[Dict]:
|
| 245 |
+
"""
|
| 246 |
+
Get a list of recent chat sessions.
|
| 247 |
+
|
| 248 |
+
Args:
|
| 249 |
+
limit: Maximum number of sessions to return
|
| 250 |
+
|
| 251 |
+
Returns:
|
| 252 |
+
List of session metadata dictionaries
|
| 253 |
+
"""
|
| 254 |
+
if not self.base_path.exists():
|
| 255 |
+
return []
|
| 256 |
+
|
| 257 |
+
sessions = []
|
| 258 |
+
session_dirs = [d for d in self.base_path.iterdir() if d.is_dir()]
|
| 259 |
+
|
| 260 |
+
# Sort by modification time (most recent first)
|
| 261 |
+
session_dirs.sort(key=lambda x: x.stat().st_mtime, reverse=True)
|
| 262 |
+
|
| 263 |
+
for session_dir in session_dirs[:limit]:
|
| 264 |
+
conversation_file = session_dir / "conversation.json"
|
| 265 |
+
if conversation_file.exists():
|
| 266 |
+
try:
|
| 267 |
+
with open(conversation_file, 'r', encoding='utf-8') as f:
|
| 268 |
+
data = json.load(f)
|
| 269 |
+
sessions.append({
|
| 270 |
+
"session_id": data.get("session_id", session_dir.name),
|
| 271 |
+
"path": str(session_dir),
|
| 272 |
+
"created_at": data.get("created_at"),
|
| 273 |
+
"last_updated": data.get("last_updated"),
|
| 274 |
+
"message_count": len(data.get("messages", [])),
|
| 275 |
+
"first_user_message": next(
|
| 276 |
+
(msg["content"] for msg in data.get("messages", [])
|
| 277 |
+
if msg["role"] == "user"),
|
| 278 |
+
"No user message"
|
| 279 |
+
)[:100] # First 100 chars of first user message
|
| 280 |
+
})
|
| 281 |
+
except (json.JSONDecodeError, IOError):
|
| 282 |
+
continue
|
| 283 |
+
|
| 284 |
+
return sessions
|
| 285 |
+
|
| 286 |
+
def clear_old_sessions(self, days: int = 30):
|
| 287 |
+
"""
|
| 288 |
+
Clear sessions older than specified days.
|
| 289 |
+
|
| 290 |
+
Args:
|
| 291 |
+
days: Number of days to keep sessions
|
| 292 |
+
"""
|
| 293 |
+
if not self.base_path.exists():
|
| 294 |
+
return
|
| 295 |
+
|
| 296 |
+
current_time = time.time()
|
| 297 |
+
cutoff_time = current_time - (days * 24 * 3600)
|
| 298 |
+
|
| 299 |
+
for session_dir in self.base_path.iterdir():
|
| 300 |
+
if session_dir.is_dir():
|
| 301 |
+
# Check modification time
|
| 302 |
+
if session_dir.stat().st_mtime < cutoff_time:
|
| 303 |
+
# Remove old session
|
| 304 |
+
try:
|
| 305 |
+
import shutil
|
| 306 |
+
shutil.rmtree(session_dir)
|
| 307 |
+
print(f"Removed old session: {session_dir.name}")
|
| 308 |
+
except Exception as e:
|
| 309 |
+
print(f"Error removing session {session_dir.name}: {e}")
|
custom_nodes/openrouter_node/examples/chat_mode_example.json
ADDED
|
@@ -0,0 +1,130 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"last_node_id": 3,
|
| 3 |
+
"last_link_id": 2,
|
| 4 |
+
"nodes": [
|
| 5 |
+
{
|
| 6 |
+
"id": 1,
|
| 7 |
+
"type": "OpenRouterNode",
|
| 8 |
+
"pos": [400, 200],
|
| 9 |
+
"size": [400, 400],
|
| 10 |
+
"flags": {},
|
| 11 |
+
"order": 0,
|
| 12 |
+
"mode": 0,
|
| 13 |
+
"inputs": [],
|
| 14 |
+
"outputs": [
|
| 15 |
+
{
|
| 16 |
+
"name": "Output",
|
| 17 |
+
"type": "STRING",
|
| 18 |
+
"links": [1],
|
| 19 |
+
"shape": 3,
|
| 20 |
+
"slot_index": 0
|
| 21 |
+
},
|
| 22 |
+
{
|
| 23 |
+
"name": "Stats",
|
| 24 |
+
"type": "STRING",
|
| 25 |
+
"links": [2],
|
| 26 |
+
"shape": 3,
|
| 27 |
+
"slot_index": 1
|
| 28 |
+
},
|
| 29 |
+
{
|
| 30 |
+
"name": "Credits",
|
| 31 |
+
"type": "STRING",
|
| 32 |
+
"links": null,
|
| 33 |
+
"shape": 3
|
| 34 |
+
}
|
| 35 |
+
],
|
| 36 |
+
"properties": {
|
| 37 |
+
"Node name for S&R": "OpenRouterNode"
|
| 38 |
+
},
|
| 39 |
+
"widgets_values": [
|
| 40 |
+
"",
|
| 41 |
+
"You are a helpful AI assistant. Please provide thoughtful and accurate responses.",
|
| 42 |
+
"Hello! Let's have a conversation.",
|
| 43 |
+
"openai/gpt-3.5-turbo",
|
| 44 |
+
false,
|
| 45 |
+
true,
|
| 46 |
+
false,
|
| 47 |
+
1,
|
| 48 |
+
"auto",
|
| 49 |
+
true
|
| 50 |
+
]
|
| 51 |
+
},
|
| 52 |
+
{
|
| 53 |
+
"id": 2,
|
| 54 |
+
"type": "ShowText|pysssss",
|
| 55 |
+
"pos": [850, 200],
|
| 56 |
+
"size": [400, 200],
|
| 57 |
+
"flags": {},
|
| 58 |
+
"order": 1,
|
| 59 |
+
"mode": 0,
|
| 60 |
+
"inputs": [
|
| 61 |
+
{
|
| 62 |
+
"name": "text",
|
| 63 |
+
"type": "STRING",
|
| 64 |
+
"link": 1
|
| 65 |
+
}
|
| 66 |
+
],
|
| 67 |
+
"outputs": [
|
| 68 |
+
{
|
| 69 |
+
"name": "STRING",
|
| 70 |
+
"type": "STRING",
|
| 71 |
+
"links": null,
|
| 72 |
+
"shape": 6
|
| 73 |
+
}
|
| 74 |
+
],
|
| 75 |
+
"properties": {
|
| 76 |
+
"Node name for S&R": "ShowText|pysssss"
|
| 77 |
+
},
|
| 78 |
+
"widgets_values": ["", ""]
|
| 79 |
+
},
|
| 80 |
+
{
|
| 81 |
+
"id": 3,
|
| 82 |
+
"type": "ShowText|pysssss",
|
| 83 |
+
"pos": [850, 450],
|
| 84 |
+
"size": [400, 100],
|
| 85 |
+
"flags": {},
|
| 86 |
+
"order": 2,
|
| 87 |
+
"mode": 0,
|
| 88 |
+
"inputs": [
|
| 89 |
+
{
|
| 90 |
+
"name": "text",
|
| 91 |
+
"type": "STRING",
|
| 92 |
+
"link": 2
|
| 93 |
+
}
|
| 94 |
+
],
|
| 95 |
+
"outputs": [
|
| 96 |
+
{
|
| 97 |
+
"name": "STRING",
|
| 98 |
+
"type": "STRING",
|
| 99 |
+
"links": null,
|
| 100 |
+
"shape": 6
|
| 101 |
+
}
|
| 102 |
+
],
|
| 103 |
+
"properties": {
|
| 104 |
+
"Node name for S&R": "ShowText|pysssss"
|
| 105 |
+
},
|
| 106 |
+
"widgets_values": ["", ""]
|
| 107 |
+
}
|
| 108 |
+
],
|
| 109 |
+
"links": [
|
| 110 |
+
[1, 1, 0, 2, 0, "STRING"],
|
| 111 |
+
[2, 1, 1, 3, 0, "STRING"]
|
| 112 |
+
],
|
| 113 |
+
"groups": [
|
| 114 |
+
{
|
| 115 |
+
"title": "Chat Mode Example",
|
| 116 |
+
"bounding": [380, 120, 900, 500],
|
| 117 |
+
"color": "#3f789e",
|
| 118 |
+
"font_size": 24,
|
| 119 |
+
"locked": false
|
| 120 |
+
}
|
| 121 |
+
],
|
| 122 |
+
"config": {},
|
| 123 |
+
"extra": {
|
| 124 |
+
"workflow": {
|
| 125 |
+
"title": "OpenRouter Chat Mode Example",
|
| 126 |
+
"description": "This workflow demonstrates the chat mode feature. When chat mode is enabled, conversations are automatically saved and context is maintained across messages. Sessions expire after 1 hour of inactivity."
|
| 127 |
+
}
|
| 128 |
+
},
|
| 129 |
+
"version": 0.4
|
| 130 |
+
}
|
custom_nodes/openrouter_node/manage_chats.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Chat Session Management Utility for OpenRouter Node
|
| 4 |
+
|
| 5 |
+
This script provides utilities to manage chat sessions:
|
| 6 |
+
- List all chat sessions
|
| 7 |
+
- View a specific session
|
| 8 |
+
- Export sessions to different formats
|
| 9 |
+
- Clean up old sessions
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
import argparse
|
| 13 |
+
import json
|
| 14 |
+
import os
|
| 15 |
+
import sys
|
| 16 |
+
from datetime import datetime
|
| 17 |
+
from pathlib import Path
|
| 18 |
+
from chat_manager import ChatSessionManager
|
| 19 |
+
|
| 20 |
+
def list_sessions(manager: ChatSessionManager, limit: int = None):
|
| 21 |
+
"""List all chat sessions"""
|
| 22 |
+
sessions = manager.get_recent_sessions(limit or 1000)
|
| 23 |
+
|
| 24 |
+
if not sessions:
|
| 25 |
+
print("No chat sessions found.")
|
| 26 |
+
return
|
| 27 |
+
|
| 28 |
+
print(f"\nFound {len(sessions)} chat session(s):\n")
|
| 29 |
+
print(f"{'#':<4} {'Session ID':<50} {'Created':<20} {'Messages':<10} {'First Message':<50}")
|
| 30 |
+
print("-" * 140)
|
| 31 |
+
|
| 32 |
+
for i, session in enumerate(sessions, 1):
|
| 33 |
+
created = session.get('created_at', 'Unknown')
|
| 34 |
+
if created != 'Unknown':
|
| 35 |
+
try:
|
| 36 |
+
dt = datetime.fromisoformat(created.replace('Z', '+00:00'))
|
| 37 |
+
created = dt.strftime('%Y-%m-%d %H:%M')
|
| 38 |
+
except:
|
| 39 |
+
pass
|
| 40 |
+
|
| 41 |
+
print(f"{i:<4} {session['session_id'][:50]:<50} {created:<20} {session['message_count']:<10} {session['first_user_message'][:50]:<50}")
|
| 42 |
+
|
| 43 |
+
def view_session(manager: ChatSessionManager, session_id: str):
|
| 44 |
+
"""View a specific chat session"""
|
| 45 |
+
session_path = manager.base_path / session_id
|
| 46 |
+
|
| 47 |
+
if not session_path.exists():
|
| 48 |
+
print(f"Error: Session '{session_id}' not found.")
|
| 49 |
+
return
|
| 50 |
+
|
| 51 |
+
messages = manager.load_conversation(session_path)
|
| 52 |
+
|
| 53 |
+
if not messages:
|
| 54 |
+
print("No messages found in this session.")
|
| 55 |
+
return
|
| 56 |
+
|
| 57 |
+
print(f"\n=== Chat Session: {session_id} ===\n")
|
| 58 |
+
|
| 59 |
+
for msg in messages:
|
| 60 |
+
role = msg['role'].upper()
|
| 61 |
+
content = msg['content']
|
| 62 |
+
timestamp = msg.get('timestamp', '')
|
| 63 |
+
|
| 64 |
+
if timestamp:
|
| 65 |
+
try:
|
| 66 |
+
dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00'))
|
| 67 |
+
timestamp = dt.strftime(' [%Y-%m-%d %H:%M:%S]')
|
| 68 |
+
except:
|
| 69 |
+
timestamp = ''
|
| 70 |
+
|
| 71 |
+
print(f"{role}{timestamp}:")
|
| 72 |
+
print(f"{content}")
|
| 73 |
+
print("-" * 80)
|
| 74 |
+
|
| 75 |
+
def export_session(manager: ChatSessionManager, session_id: str, output_format: str, output_file: str = None):
|
| 76 |
+
"""Export a chat session to different formats"""
|
| 77 |
+
session_path = manager.base_path / session_id
|
| 78 |
+
|
| 79 |
+
if not session_path.exists():
|
| 80 |
+
print(f"Error: Session '{session_id}' not found.")
|
| 81 |
+
return
|
| 82 |
+
|
| 83 |
+
conversation_file = session_path / "conversation.json"
|
| 84 |
+
|
| 85 |
+
if not conversation_file.exists():
|
| 86 |
+
print("No conversation file found in this session.")
|
| 87 |
+
return
|
| 88 |
+
|
| 89 |
+
with open(conversation_file, 'r', encoding='utf-8') as f:
|
| 90 |
+
data = json.load(f)
|
| 91 |
+
|
| 92 |
+
if output_file is None:
|
| 93 |
+
output_file = f"{session_id}.{output_format}"
|
| 94 |
+
|
| 95 |
+
if output_format == 'json':
|
| 96 |
+
# Pretty print JSON
|
| 97 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 98 |
+
json.dump(data, f, indent=2, ensure_ascii=False)
|
| 99 |
+
|
| 100 |
+
elif output_format == 'txt':
|
| 101 |
+
# Export as plain text
|
| 102 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 103 |
+
f.write(f"Chat Session: {data.get('session_id', 'Unknown')}\n")
|
| 104 |
+
f.write(f"Created: {data.get('created_at', 'Unknown')}\n")
|
| 105 |
+
f.write(f"Last Updated: {data.get('last_updated', 'Unknown')}\n")
|
| 106 |
+
f.write("=" * 80 + "\n\n")
|
| 107 |
+
|
| 108 |
+
for msg in data.get('messages', []):
|
| 109 |
+
role = msg['role'].upper()
|
| 110 |
+
content = msg['content']
|
| 111 |
+
timestamp = msg.get('timestamp', '')
|
| 112 |
+
|
| 113 |
+
if timestamp:
|
| 114 |
+
f.write(f"\n[{timestamp}] ")
|
| 115 |
+
f.write(f"{role}:\n{content}\n")
|
| 116 |
+
f.write("-" * 80 + "\n")
|
| 117 |
+
|
| 118 |
+
elif output_format == 'md':
|
| 119 |
+
# Export as Markdown
|
| 120 |
+
with open(output_file, 'w', encoding='utf-8') as f:
|
| 121 |
+
f.write(f"# Chat Session: {data.get('session_id', 'Unknown')}\n\n")
|
| 122 |
+
f.write(f"**Created:** {data.get('created_at', 'Unknown')} \n")
|
| 123 |
+
f.write(f"**Last Updated:** {data.get('last_updated', 'Unknown')}\n\n")
|
| 124 |
+
f.write("---\n\n")
|
| 125 |
+
|
| 126 |
+
for msg in data.get('messages', []):
|
| 127 |
+
role = msg['role']
|
| 128 |
+
content = msg['content']
|
| 129 |
+
timestamp = msg.get('timestamp', '')
|
| 130 |
+
|
| 131 |
+
if role == 'system':
|
| 132 |
+
f.write(f"### System Prompt\n\n{content}\n\n")
|
| 133 |
+
elif role == 'user':
|
| 134 |
+
f.write(f"### User")
|
| 135 |
+
if timestamp:
|
| 136 |
+
f.write(f" _{timestamp}_")
|
| 137 |
+
f.write(f"\n\n{content}\n\n")
|
| 138 |
+
elif role == 'assistant':
|
| 139 |
+
f.write(f"### Assistant")
|
| 140 |
+
if timestamp:
|
| 141 |
+
f.write(f" _{timestamp}_")
|
| 142 |
+
f.write(f"\n\n{content}\n\n")
|
| 143 |
+
|
| 144 |
+
f.write("---\n\n")
|
| 145 |
+
|
| 146 |
+
print(f"Session exported to: {output_file}")
|
| 147 |
+
|
| 148 |
+
def clean_sessions(manager: ChatSessionManager, days: int):
|
| 149 |
+
"""Clean up sessions older than specified days"""
|
| 150 |
+
print(f"\nCleaning up sessions older than {days} days...")
|
| 151 |
+
manager.clear_old_sessions(days)
|
| 152 |
+
print("Cleanup complete.")
|
| 153 |
+
|
| 154 |
+
def main():
|
| 155 |
+
parser = argparse.ArgumentParser(description='Manage OpenRouter chat sessions')
|
| 156 |
+
|
| 157 |
+
# Create subparsers for different commands
|
| 158 |
+
subparsers = parser.add_subparsers(dest='command', help='Available commands')
|
| 159 |
+
|
| 160 |
+
# List command
|
| 161 |
+
list_parser = subparsers.add_parser('list', help='List all chat sessions')
|
| 162 |
+
list_parser.add_argument('-l', '--limit', type=int, help='Limit number of sessions to display')
|
| 163 |
+
|
| 164 |
+
# View command
|
| 165 |
+
view_parser = subparsers.add_parser('view', help='View a specific chat session')
|
| 166 |
+
view_parser.add_argument('session_id', help='Session ID to view')
|
| 167 |
+
|
| 168 |
+
# Export command
|
| 169 |
+
export_parser = subparsers.add_parser('export', help='Export a chat session')
|
| 170 |
+
export_parser.add_argument('session_id', help='Session ID to export')
|
| 171 |
+
export_parser.add_argument('-f', '--format', choices=['json', 'txt', 'md'],
|
| 172 |
+
default='txt', help='Export format (default: txt)')
|
| 173 |
+
export_parser.add_argument('-o', '--output', help='Output filename')
|
| 174 |
+
|
| 175 |
+
# Clean command
|
| 176 |
+
clean_parser = subparsers.add_parser('clean', help='Clean up old sessions')
|
| 177 |
+
clean_parser.add_argument('-d', '--days', type=int, default=30,
|
| 178 |
+
help='Remove sessions older than this many days (default: 30)')
|
| 179 |
+
|
| 180 |
+
args = parser.parse_args()
|
| 181 |
+
|
| 182 |
+
# Initialize chat manager
|
| 183 |
+
manager = ChatSessionManager()
|
| 184 |
+
|
| 185 |
+
if args.command == 'list':
|
| 186 |
+
list_sessions(manager, args.limit)
|
| 187 |
+
elif args.command == 'view':
|
| 188 |
+
view_session(manager, args.session_id)
|
| 189 |
+
elif args.command == 'export':
|
| 190 |
+
export_session(manager, args.session_id, args.format, args.output)
|
| 191 |
+
elif args.command == 'clean':
|
| 192 |
+
clean_sessions(manager, args.days)
|
| 193 |
+
else:
|
| 194 |
+
parser.print_help()
|
| 195 |
+
sys.exit(1)
|
| 196 |
+
|
| 197 |
+
if __name__ == '__main__':
|
| 198 |
+
main()
|
custom_nodes/openrouter_node/nano_banana_example.jpeg
ADDED
|
Git LFS Details
|
custom_nodes/openrouter_node/node.py
ADDED
|
@@ -0,0 +1,748 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import requests
|
| 2 |
+
import json
|
| 3 |
+
import time
|
| 4 |
+
import base64
|
| 5 |
+
import io
|
| 6 |
+
import numpy as np
|
| 7 |
+
import torch
|
| 8 |
+
import tiktoken
|
| 9 |
+
from PIL import Image
|
| 10 |
+
import hashlib # Added for hashing PDF bytes in IS_CHANGED
|
| 11 |
+
import os
|
| 12 |
+
from .chat_manager import ChatSessionManager
|
| 13 |
+
|
| 14 |
+
# Define a placeholder type name for PDF data.
|
| 15 |
+
# The actual input connection will accept '*' but we check the structure.
|
| 16 |
+
# Expecting a dictionary: {"filename": str, "bytes": bytes}
|
| 17 |
+
PDF_DATA_TYPE = "*" # Use '*' to accept any type, check structure later
|
| 18 |
+
|
| 19 |
+
class OpenRouterNode:
|
| 20 |
+
"""
|
| 21 |
+
A node for interacting with OpenRouter's chat/completion API.
|
| 22 |
+
Supports text, images, and PDFs as input.
|
| 23 |
+
Returns three outputs:
|
| 24 |
+
1) "Output": the text response from the LLM
|
| 25 |
+
2) "Stats": a string detailing tokens per second, input tokens, and output tokens
|
| 26 |
+
3) "Credits": a string showing your remaining OpenRouter account balance
|
| 27 |
+
"""
|
| 28 |
+
|
| 29 |
+
models_cache = None
|
| 30 |
+
last_fetch_time = 0
|
| 31 |
+
cache_duration = 3600 # Cache duration in seconds (1 hour)
|
| 32 |
+
default_request_timeout = 120
|
| 33 |
+
min_request_timeout = 1
|
| 34 |
+
max_request_timeout = 3600
|
| 35 |
+
reasoning_effort_options = ("auto", "none", "minimal", "low", "medium", "high", "xhigh")
|
| 36 |
+
default_reasoning_effort = "auto"
|
| 37 |
+
|
| 38 |
+
def __init__(self):
|
| 39 |
+
self.chat_manager = ChatSessionManager()
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def get_api_key(api_key_ui):
|
| 43 |
+
"""
|
| 44 |
+
Resolves the API key from:
|
| 45 |
+
1. UI input field (if not empty)
|
| 46 |
+
2. Environment variable 'LLM_KEY'
|
| 47 |
+
3. config file 'openrouter_api_key.json' in node directory
|
| 48 |
+
"""
|
| 49 |
+
if api_key_ui and api_key_ui.strip():
|
| 50 |
+
return api_key_ui.strip()
|
| 51 |
+
|
| 52 |
+
# Check environment variable
|
| 53 |
+
env_key = os.environ.get("LLM_KEY")
|
| 54 |
+
if env_key and env_key.strip():
|
| 55 |
+
return env_key.strip()
|
| 56 |
+
|
| 57 |
+
# Check JSON file
|
| 58 |
+
config_path = os.path.join(os.path.dirname(os.path.realpath(__file__)), "openrouter_api_key.json")
|
| 59 |
+
if os.path.exists(config_path):
|
| 60 |
+
try:
|
| 61 |
+
with open(config_path, 'r') as f:
|
| 62 |
+
config = json.load(f)
|
| 63 |
+
file_key = config.get("api_key")
|
| 64 |
+
if file_key and file_key.strip():
|
| 65 |
+
return file_key.strip()
|
| 66 |
+
except Exception as e:
|
| 67 |
+
print(f"Error reading openrouter_api_key.json: {e}")
|
| 68 |
+
|
| 69 |
+
return ""
|
| 70 |
+
|
| 71 |
+
@classmethod
|
| 72 |
+
def INPUT_TYPES(cls):
|
| 73 |
+
"""
|
| 74 |
+
Defines the input specification for this node.
|
| 75 |
+
Includes optional inputs for image and PDF data.
|
| 76 |
+
"""
|
| 77 |
+
return {
|
| 78 |
+
"required": {
|
| 79 |
+
"api_key": ("STRING", {
|
| 80 |
+
"multiline": False,
|
| 81 |
+
"default": ""
|
| 82 |
+
}),
|
| 83 |
+
"system_prompt": ("STRING", {
|
| 84 |
+
"multiline": True,
|
| 85 |
+
"default": "You are a helpful assistant."
|
| 86 |
+
}),
|
| 87 |
+
"user_message_box": ("STRING", {
|
| 88 |
+
"multiline": True,
|
| 89 |
+
"default": "Hello, how are you?"
|
| 90 |
+
}),
|
| 91 |
+
"model": (cls.fetch_openrouter_models(),),
|
| 92 |
+
"web_search": ("BOOLEAN", {"default": False}),
|
| 93 |
+
"cheapest": ("BOOLEAN", {"default": True}),
|
| 94 |
+
"fastest": ("BOOLEAN", {"default": False}),
|
| 95 |
+
"aspect_ratio": ([
|
| 96 |
+
"auto",
|
| 97 |
+
"1:1 (1024x1024)",
|
| 98 |
+
"2:3 (832x1248)",
|
| 99 |
+
"3:2 (1248x832)",
|
| 100 |
+
"3:4 (864x1184)",
|
| 101 |
+
"4:3 (1184x864)",
|
| 102 |
+
"4:5 (896x1152)",
|
| 103 |
+
"5:4 (1152x896)",
|
| 104 |
+
"9:16 (768x1344)",
|
| 105 |
+
"16:9 (1344x768)",
|
| 106 |
+
"21:9 (1536x672)",
|
| 107 |
+
"1:4 (google/gemini-3.1-flash-image-preview (Nano Banana 2) only)",
|
| 108 |
+
"4:1 (google/gemini-3.1-flash-image-preview (Nano Banana 2) only)",
|
| 109 |
+
"1:8 (google/gemini-3.1-flash-image-preview (Nano Banana 2) only)",
|
| 110 |
+
"8:1 (google/gemini-3.1-flash-image-preview (Nano Banana 2) only)",
|
| 111 |
+
], {"default": "auto"}),
|
| 112 |
+
"image_resolution": (["1K", "2K", "4K"], {"default": "1K"}),
|
| 113 |
+
"reasoning_effort": (list(cls.reasoning_effort_options), {"default": cls.default_reasoning_effort}),
|
| 114 |
+
"seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff, "control_after_generate": "fixed"}),
|
| 115 |
+
"temperature": ("FLOAT", {
|
| 116 |
+
"default": 1.0,
|
| 117 |
+
"min": 0.0,
|
| 118 |
+
"max": 2.0,
|
| 119 |
+
"step": 0.01,
|
| 120 |
+
"display": "slider",
|
| 121 |
+
"round": 0.01,
|
| 122 |
+
}),
|
| 123 |
+
"pdf_engine": (["auto", "mistral-ocr", "pdf-text"], {"default": "auto"}),
|
| 124 |
+
"chat_mode": ("BOOLEAN", {"default": False}),
|
| 125 |
+
"request_timeout": ("INT", {
|
| 126 |
+
"default": cls.default_request_timeout,
|
| 127 |
+
"min": cls.min_request_timeout,
|
| 128 |
+
"max": cls.max_request_timeout,
|
| 129 |
+
"step": 1,
|
| 130 |
+
"display": "number",
|
| 131 |
+
}),
|
| 132 |
+
},
|
| 133 |
+
"optional": {
|
| 134 |
+
"pdf_data": (PDF_DATA_TYPE,), # Use '*' and check structure in generate_response
|
| 135 |
+
"user_message_input": ("STRING", {"forceInput": True}),
|
| 136 |
+
}
|
| 137 |
+
}
|
| 138 |
+
|
| 139 |
+
RETURN_TYPES = ("STRING", "IMAGE", "STRING", "STRING",)
|
| 140 |
+
RETURN_NAMES = ("Output", "image", "Stats", "Credits")
|
| 141 |
+
|
| 142 |
+
FUNCTION = "generate_response"
|
| 143 |
+
CATEGORY = "LLM"
|
| 144 |
+
|
| 145 |
+
@classmethod
|
| 146 |
+
def fetch_openrouter_models(cls):
|
| 147 |
+
"""
|
| 148 |
+
Fetches a list of model IDs from the OpenRouter API, caching them.
|
| 149 |
+
"""
|
| 150 |
+
current_time = time.time()
|
| 151 |
+
if (cls.models_cache is None) or (current_time - cls.last_fetch_time > cls.cache_duration):
|
| 152 |
+
url = "https://openrouter.ai/api/v1/models"
|
| 153 |
+
try:
|
| 154 |
+
response = requests.get(url, timeout=cls.default_request_timeout)
|
| 155 |
+
response.raise_for_status()
|
| 156 |
+
models = response.json()["data"]
|
| 157 |
+
# Filter for models that support chat completions if needed, but API handles this
|
| 158 |
+
model_list = sorted([model['id'] for model in models])
|
| 159 |
+
cls.models_cache = model_list
|
| 160 |
+
cls.last_fetch_time = current_time
|
| 161 |
+
except requests.exceptions.RequestException as e:
|
| 162 |
+
print(f"Error fetching models: {e}")
|
| 163 |
+
# Provide a default list or indicate error if cache is empty
|
| 164 |
+
if cls.models_cache is None:
|
| 165 |
+
cls.models_cache = ["error_fetching_models", "google/gemma-3-27b-it", "openai/gpt-4o"] # Example fallbacks
|
| 166 |
+
return cls.models_cache if cls.models_cache else ["error_fetching_models"] # Ensure it's never empty
|
| 167 |
+
|
| 168 |
+
def validate_temperature(self, temperature):
|
| 169 |
+
"""
|
| 170 |
+
Validates and converts temperature value to float within acceptable range.
|
| 171 |
+
"""
|
| 172 |
+
try:
|
| 173 |
+
temp = float(temperature)
|
| 174 |
+
return max(0.0, min(2.0, temp)) # Clamp between 0.0 and 2.0
|
| 175 |
+
except (ValueError, TypeError):
|
| 176 |
+
return 1.0 # Return default if conversion fails
|
| 177 |
+
|
| 178 |
+
def validate_request_timeout(self, request_timeout):
|
| 179 |
+
"""
|
| 180 |
+
Validates and converts request timeout to seconds within an acceptable range.
|
| 181 |
+
"""
|
| 182 |
+
try:
|
| 183 |
+
timeout = int(request_timeout)
|
| 184 |
+
return max(self.min_request_timeout, min(self.max_request_timeout, timeout))
|
| 185 |
+
except (ValueError, TypeError):
|
| 186 |
+
return self.default_request_timeout
|
| 187 |
+
|
| 188 |
+
@classmethod
|
| 189 |
+
def validate_reasoning_effort(cls, reasoning_effort):
|
| 190 |
+
"""
|
| 191 |
+
Validates OpenRouter reasoning effort. "auto" means do not send a
|
| 192 |
+
reasoning override and let OpenRouter/model defaults apply.
|
| 193 |
+
"""
|
| 194 |
+
if isinstance(reasoning_effort, str):
|
| 195 |
+
normalized_effort = reasoning_effort.strip().lower()
|
| 196 |
+
if normalized_effort in cls.reasoning_effort_options:
|
| 197 |
+
return normalized_effort
|
| 198 |
+
return cls.default_reasoning_effort
|
| 199 |
+
|
| 200 |
+
def fetch_credits(self, api_key, timeout=None):
|
| 201 |
+
"""
|
| 202 |
+
Fetches the user's credits information from the OpenRouter API.
|
| 203 |
+
Returns a formatted string with remaining credits.
|
| 204 |
+
"""
|
| 205 |
+
api_key = self.get_api_key(api_key)
|
| 206 |
+
if not api_key:
|
| 207 |
+
return "API Key not provided."
|
| 208 |
+
|
| 209 |
+
url = "https://openrouter.ai/api/v1/credits"
|
| 210 |
+
headers = {
|
| 211 |
+
"Authorization": f"Bearer {api_key}",
|
| 212 |
+
"Content-Type": "application/json",
|
| 213 |
+
"HTTP-Referer": "https://github.com/yourusername/comfyui-openrouter",
|
| 214 |
+
"X-Title": "ComfyUI OpenRouter LLM Node",
|
| 215 |
+
}
|
| 216 |
+
|
| 217 |
+
try:
|
| 218 |
+
validated_timeout = self.validate_request_timeout(timeout)
|
| 219 |
+
response = requests.get(url, headers=headers, timeout=validated_timeout)
|
| 220 |
+
response.raise_for_status()
|
| 221 |
+
|
| 222 |
+
result = response.json()
|
| 223 |
+
# Check if 'data' and expected keys exist
|
| 224 |
+
if "data" in result and "total_credits" in result["data"] and "total_usage" in result["data"]:
|
| 225 |
+
total_credits = result["data"]["total_credits"]
|
| 226 |
+
total_usage = result["data"]["total_usage"]
|
| 227 |
+
remaining = total_credits - total_usage
|
| 228 |
+
credits_text = f"Remaining: ${remaining:.3f}"
|
| 229 |
+
else:
|
| 230 |
+
credits_text = "Could not parse credit data from response."
|
| 231 |
+
|
| 232 |
+
return credits_text
|
| 233 |
+
|
| 234 |
+
except requests.exceptions.RequestException as e:
|
| 235 |
+
# Provide more context about the error
|
| 236 |
+
error_message = f"Error fetching credits: {str(e)}"
|
| 237 |
+
if hasattr(e, 'response') and e.response is not None:
|
| 238 |
+
error_message += f" | Status Code: {e.response.status_code} | Response: {e.response.text[:200]}" # Log part of response
|
| 239 |
+
return error_message
|
| 240 |
+
except json.JSONDecodeError:
|
| 241 |
+
return "Error fetching credits: Could not decode JSON response."
|
| 242 |
+
|
| 243 |
+
def generate_response(self, api_key, system_prompt, user_message_box, model,
|
| 244 |
+
web_search, cheapest, fastest, temperature, pdf_engine, chat_mode,
|
| 245 |
+
request_timeout=120, aspect_ratio="auto", image_resolution="1K", seed=0,
|
| 246 |
+
pdf_data=None, user_message_input=None, reasoning_effort="auto", **kwargs):
|
| 247 |
+
"""
|
| 248 |
+
Sends a completion request to the OpenRouter chat completion endpoint.
|
| 249 |
+
Handles text, optional image, and optional PDF inputs.
|
| 250 |
+
|
| 251 |
+
Returns four outputs:
|
| 252 |
+
(1) Output: the LLM's text response
|
| 253 |
+
(2) image: an image tensor if the response contains an image, else empty tensor
|
| 254 |
+
(3) Stats: a string with tokens per second, prompt tokens, completion tokens
|
| 255 |
+
(4) Credits: a string with the user's credit information
|
| 256 |
+
"""
|
| 257 |
+
# Create empty placeholder image
|
| 258 |
+
placeholder_image = torch.zeros((1, 1, 1, 3), dtype=torch.float32)
|
| 259 |
+
|
| 260 |
+
# Resolve API key
|
| 261 |
+
api_key = self.get_api_key(api_key)
|
| 262 |
+
|
| 263 |
+
if not api_key:
|
| 264 |
+
return ("Error: API Key not provided. Set LLM_KEY env var or use openrouter_api_key.json", placeholder_image, "Stats N/A", "Credits N/A")
|
| 265 |
+
|
| 266 |
+
url = "https://openrouter.ai/api/v1/chat/completions"
|
| 267 |
+
headers = {
|
| 268 |
+
"Authorization": f"Bearer {api_key}",
|
| 269 |
+
"Content-Type": "application/json",
|
| 270 |
+
"HTTP-Referer": "https://github.com/yourusername/comfyui-openrouter",
|
| 271 |
+
"X-Title": "ComfyUI OpenRouter LLM Node",
|
| 272 |
+
}
|
| 273 |
+
|
| 274 |
+
# Validate and convert temperature
|
| 275 |
+
validated_temp = self.validate_temperature(temperature)
|
| 276 |
+
validated_timeout = self.validate_request_timeout(request_timeout)
|
| 277 |
+
validated_reasoning_effort = self.validate_reasoning_effort(reasoning_effort)
|
| 278 |
+
|
| 279 |
+
# Decide whether to use user_message_input or user_message_box
|
| 280 |
+
user_text = user_message_input if user_message_input is not None and user_message_input.strip() else user_message_box
|
| 281 |
+
|
| 282 |
+
# Initialize session_path
|
| 283 |
+
session_path = None
|
| 284 |
+
|
| 285 |
+
# Handle chat mode
|
| 286 |
+
if chat_mode:
|
| 287 |
+
# Get or create a chat session
|
| 288 |
+
session_path, messages = self.chat_manager.get_or_create_session(user_text, system_prompt)
|
| 289 |
+
|
| 290 |
+
# Check if we need to update the system prompt (for existing sessions)
|
| 291 |
+
if messages and messages[0]["role"] == "system" and messages[0]["content"] != system_prompt:
|
| 292 |
+
# Update system prompt if it has changed
|
| 293 |
+
messages[0]["content"] = system_prompt
|
| 294 |
+
else:
|
| 295 |
+
# Non-chat mode: Build the messages array, starting with a system prompt.
|
| 296 |
+
messages = [
|
| 297 |
+
{"role": "system", "content": system_prompt},
|
| 298 |
+
]
|
| 299 |
+
|
| 300 |
+
# --- Build the user message content ---
|
| 301 |
+
user_content_blocks = []
|
| 302 |
+
|
| 303 |
+
# 1. Add Text part (always present)
|
| 304 |
+
user_content_blocks.append({
|
| 305 |
+
"type": "text",
|
| 306 |
+
"text": user_text
|
| 307 |
+
})
|
| 308 |
+
|
| 309 |
+
# 2. Add Image parts (optional) - support multiple images from kwargs
|
| 310 |
+
# Process all image_N inputs from kwargs
|
| 311 |
+
image_keys = sorted([k for k in kwargs.keys() if k.startswith('image_')],
|
| 312 |
+
key=lambda x: int(x.split('_')[1]))
|
| 313 |
+
|
| 314 |
+
for image_key in image_keys:
|
| 315 |
+
if kwargs[image_key] is not None:
|
| 316 |
+
try:
|
| 317 |
+
img_str = self.image_to_base64(kwargs[image_key])
|
| 318 |
+
user_content_blocks.append({
|
| 319 |
+
"type": "image_url",
|
| 320 |
+
"image_url": {
|
| 321 |
+
"url": f"data:image/png;base64,{img_str}"
|
| 322 |
+
}
|
| 323 |
+
})
|
| 324 |
+
except Exception as e:
|
| 325 |
+
print(f"Error processing {image_key}: {e}")
|
| 326 |
+
return (f"Error processing {image_key}: {e}", placeholder_image, "Stats N/A", "Credits N/A")
|
| 327 |
+
|
| 328 |
+
# 3. Add PDF part (optional)
|
| 329 |
+
pdf_filename = "document.pdf" # Default filename if not provided
|
| 330 |
+
if pdf_data is not None:
|
| 331 |
+
# Validate pdf_data structure (expecting dict with 'filename' and 'bytes')
|
| 332 |
+
if isinstance(pdf_data, dict) and "bytes" in pdf_data and isinstance(pdf_data["bytes"], bytes):
|
| 333 |
+
pdf_bytes = pdf_data["bytes"]
|
| 334 |
+
# Use provided filename if available and valid, otherwise use default
|
| 335 |
+
if "filename" in pdf_data and isinstance(pdf_data["filename"], str) and pdf_data["filename"].strip():
|
| 336 |
+
pdf_filename = pdf_data["filename"]
|
| 337 |
+
|
| 338 |
+
try:
|
| 339 |
+
base64_pdf = base64.b64encode(pdf_bytes).decode('utf-8')
|
| 340 |
+
data_url = f"data:application/pdf;base64,{base64_pdf}"
|
| 341 |
+
user_content_blocks.append({
|
| 342 |
+
"type": "file",
|
| 343 |
+
"file": {
|
| 344 |
+
"filename": pdf_filename,
|
| 345 |
+
"file_data": data_url
|
| 346 |
+
}
|
| 347 |
+
})
|
| 348 |
+
except Exception as e:
|
| 349 |
+
print(f"Error encoding PDF: {e}")
|
| 350 |
+
return (f"Error encoding PDF: {e}", placeholder_image, "Stats N/A", "Credits N/A")
|
| 351 |
+
else:
|
| 352 |
+
# Handle case where pdf_data is not in the expected format
|
| 353 |
+
print(f"Warning: pdf_data input is not in the expected format (dict with 'filename' and 'bytes'). PDF not included.")
|
| 354 |
+
# Optionally return an error or just proceed without the PDF
|
| 355 |
+
# return ("Error: Invalid PDF data format.", "Stats N/A", "Credits N/A")
|
| 356 |
+
|
| 357 |
+
|
| 358 |
+
# Determine message format based on content type
|
| 359 |
+
# Use simple string format for text-only requests to ensure compatibility
|
| 360 |
+
# Use structured format only when we have multimodal content
|
| 361 |
+
has_multimodal_content = len(user_content_blocks) > 1 or any(block.get("type") != "text" for block in user_content_blocks)
|
| 362 |
+
|
| 363 |
+
if has_multimodal_content:
|
| 364 |
+
# Use structured format for multimodal content
|
| 365 |
+
new_user_message = {
|
| 366 |
+
"role": "user",
|
| 367 |
+
"content": user_content_blocks
|
| 368 |
+
}
|
| 369 |
+
else:
|
| 370 |
+
# Use simple string format for text-only requests
|
| 371 |
+
new_user_message = {
|
| 372 |
+
"role": "user",
|
| 373 |
+
"content": user_text
|
| 374 |
+
}
|
| 375 |
+
|
| 376 |
+
if chat_mode:
|
| 377 |
+
# In chat mode, append to existing conversation (but don't save yet - wait for response)
|
| 378 |
+
messages.append(new_user_message)
|
| 379 |
+
else:
|
| 380 |
+
# In non-chat mode, messages array already has system prompt, just append user message
|
| 381 |
+
messages.append(new_user_message)
|
| 382 |
+
|
| 383 |
+
# --- Apply model modifiers ---
|
| 384 |
+
modified_model = model
|
| 385 |
+
# Check if model already has modifiers to avoid duplication
|
| 386 |
+
if web_search and ":online" not in modified_model:
|
| 387 |
+
modified_model = f"{modified_model}:online"
|
| 388 |
+
if ":online" not in modified_model:
|
| 389 |
+
if cheapest and ":floor" not in modified_model:
|
| 390 |
+
modified_model = f"{modified_model}:floor"
|
| 391 |
+
elif fastest and not cheapest and ":nitro" not in modified_model:
|
| 392 |
+
modified_model = f"{modified_model}:nitro"
|
| 393 |
+
|
| 394 |
+
|
| 395 |
+
# --- Construct the final payload ---
|
| 396 |
+
data = {
|
| 397 |
+
"model": modified_model,
|
| 398 |
+
"messages": messages,
|
| 399 |
+
"temperature": validated_temp,
|
| 400 |
+
"seed": seed
|
| 401 |
+
}
|
| 402 |
+
if validated_reasoning_effort != "auto":
|
| 403 |
+
data["reasoning"] = {"effort": validated_reasoning_effort}
|
| 404 |
+
|
| 405 |
+
print(f"Payload: model={modified_model}")
|
| 406 |
+
|
| 407 |
+
# Add plugins if a specific PDF engine is selected
|
| 408 |
+
if pdf_engine != "auto":
|
| 409 |
+
data["plugins"] = [
|
| 410 |
+
{
|
| 411 |
+
"id": "file-parser",
|
| 412 |
+
"pdf": {
|
| 413 |
+
"engine": pdf_engine
|
| 414 |
+
}
|
| 415 |
+
}
|
| 416 |
+
]
|
| 417 |
+
|
| 418 |
+
# --- Pre-calculate text input tokens (rough estimate) ---
|
| 419 |
+
# Note: Actual token count depends on the model and includes parsed PDF/image data.
|
| 420 |
+
# Rely on the API response for accurate usage stats.
|
| 421 |
+
text_token_estimate = 0
|
| 422 |
+
try:
|
| 423 |
+
text_token_estimate = self.count_tokens(system_prompt, model) + self.count_tokens(user_text, model)
|
| 424 |
+
except Exception as e:
|
| 425 |
+
print(f"Warning: Token counting failed - {e}")
|
| 426 |
+
|
| 427 |
+
|
| 428 |
+
# --- Make API Call and Process Response ---
|
| 429 |
+
try:
|
| 430 |
+
start_time = time.time()
|
| 431 |
+
response = requests.post(url, headers=headers, json=data, timeout=validated_timeout)
|
| 432 |
+
response.raise_for_status() # Raises HTTPError for bad responses (4xx or 5xx)
|
| 433 |
+
end_time = time.time()
|
| 434 |
+
|
| 435 |
+
result = response.json()
|
| 436 |
+
# Debug: print truncated response to see what OpenRouter returned
|
| 437 |
+
debug_str = json.dumps(result, default=str)
|
| 438 |
+
print(f"API response ({len(debug_str)} chars): {debug_str[:500]}")
|
| 439 |
+
|
| 440 |
+
# --- Extract results and calculate stats ---
|
| 441 |
+
if not result.get("choices") or not result["choices"][0].get("message"):
|
| 442 |
+
raise ValueError("Invalid response format from API: 'choices' or 'message' missing.")
|
| 443 |
+
|
| 444 |
+
# Parse response for text and image content
|
| 445 |
+
message = result["choices"][0]["message"]
|
| 446 |
+
text_output = message.get("content", "")
|
| 447 |
+
image_tensor = placeholder_image
|
| 448 |
+
|
| 449 |
+
# Check for images in the separate images field (OpenRouter format)
|
| 450 |
+
if message.get("images"):
|
| 451 |
+
print(f"Found {len(message['images'])} image(s) in API response")
|
| 452 |
+
try:
|
| 453 |
+
# Get the first image from the images array
|
| 454 |
+
first_image = message["images"][0]
|
| 455 |
+
image_url = first_image["image_url"]["url"]
|
| 456 |
+
|
| 457 |
+
if image_url.startswith("data:image"):
|
| 458 |
+
base64_str = image_url.split(",", 1)[1]
|
| 459 |
+
try:
|
| 460 |
+
# Convert base64 to image tensor
|
| 461 |
+
image_tensor = self.base64_to_image(base64_str)
|
| 462 |
+
print(f"Successfully decoded image from API response")
|
| 463 |
+
except Exception as e:
|
| 464 |
+
print(f"Error decoding image: {e}")
|
| 465 |
+
else:
|
| 466 |
+
print(f"Image URL format not supported: {image_url[:50]}...")
|
| 467 |
+
except Exception as e:
|
| 468 |
+
print(f"Error processing images from response: {e}")
|
| 469 |
+
else:
|
| 470 |
+
print("No images found in API response - this may be normal if the model doesn't support image generation or the prompt didn't request an image")
|
| 471 |
+
|
| 472 |
+
# Also handle legacy multimodal content format as fallback
|
| 473 |
+
if isinstance(text_output, list):
|
| 474 |
+
text_parts = []
|
| 475 |
+
for content in text_output:
|
| 476 |
+
if isinstance(content, dict):
|
| 477 |
+
if content.get("type") == "text":
|
| 478 |
+
text_parts.append(content.get("text", ""))
|
| 479 |
+
elif content.get("type") == "image_url":
|
| 480 |
+
# Extract base64 image data
|
| 481 |
+
image_url = content["image_url"]["url"]
|
| 482 |
+
if image_url.startswith("data:image"):
|
| 483 |
+
base64_str = image_url.split(",", 1)[1]
|
| 484 |
+
try:
|
| 485 |
+
# Convert base64 to image tensor
|
| 486 |
+
image_tensor = self.base64_to_image(base64_str)
|
| 487 |
+
except Exception as e:
|
| 488 |
+
print(f"Error decoding image: {e}")
|
| 489 |
+
text_output = "\n".join(text_parts)
|
| 490 |
+
|
| 491 |
+
response_ms = result.get("response_ms", None)
|
| 492 |
+
api_usage = result.get("usage", {})
|
| 493 |
+
prompt_tokens = api_usage.get("prompt_tokens", text_token_estimate) # Use API value if available
|
| 494 |
+
completion_tokens = api_usage.get("completion_tokens", 0)
|
| 495 |
+
if completion_tokens == 0 and text_output: # Estimate completion tokens if API doesn't provide them
|
| 496 |
+
try:
|
| 497 |
+
completion_tokens = self.count_tokens(text_output, model)
|
| 498 |
+
except Exception as e:
|
| 499 |
+
print(f"Warning: Completion token counting failed - {e}")
|
| 500 |
+
|
| 501 |
+
|
| 502 |
+
# Calculate tokens per second (TPS)
|
| 503 |
+
tps = 0
|
| 504 |
+
elapsed_time = end_time - start_time
|
| 505 |
+
if response_ms is not None:
|
| 506 |
+
server_elapsed_time = response_ms / 1000.0
|
| 507 |
+
if server_elapsed_time > 0:
|
| 508 |
+
tps = completion_tokens / server_elapsed_time
|
| 509 |
+
elif elapsed_time > 0:
|
| 510 |
+
# Use client-side timing as fallback, less accurate due to network latency
|
| 511 |
+
tps = completion_tokens / elapsed_time
|
| 512 |
+
# Optional: apply a heuristic correction factor if needed, but server time is better
|
| 513 |
+
# correction_factor = 1.28 # Example factor, might need tuning
|
| 514 |
+
# tps *= correction_factor
|
| 515 |
+
|
| 516 |
+
stats_text = (
|
| 517 |
+
f"TPS: {tps:.2f}, "
|
| 518 |
+
f"Prompt Tokens: {prompt_tokens}, "
|
| 519 |
+
f"Completion Tokens: {completion_tokens}, "
|
| 520 |
+
f"Temp: {validated_temp:.1f}, "
|
| 521 |
+
f"Model: {modified_model}" # Display the actual model used
|
| 522 |
+
)
|
| 523 |
+
if pdf_engine != "auto":
|
| 524 |
+
stats_text += f", PDF Engine: {pdf_engine}"
|
| 525 |
+
if validated_reasoning_effort != "auto":
|
| 526 |
+
stats_text += f", Reasoning: {validated_reasoning_effort}"
|
| 527 |
+
|
| 528 |
+
|
| 529 |
+
# Fetch credits information AFTER the main request
|
| 530 |
+
credits_text = self.fetch_credits(api_key, timeout=validated_timeout)
|
| 531 |
+
|
| 532 |
+
# Save conversation in chat mode
|
| 533 |
+
if chat_mode and session_path:
|
| 534 |
+
# Append assistant's response to the conversation
|
| 535 |
+
assistant_message = {
|
| 536 |
+
"role": "assistant",
|
| 537 |
+
"content": text_output
|
| 538 |
+
}
|
| 539 |
+
messages.append(assistant_message)
|
| 540 |
+
|
| 541 |
+
# Save the updated conversation
|
| 542 |
+
self.chat_manager.save_conversation(session_path, messages)
|
| 543 |
+
|
| 544 |
+
return (text_output, image_tensor, stats_text, credits_text)
|
| 545 |
+
|
| 546 |
+
except requests.exceptions.RequestException as e:
|
| 547 |
+
error_message = f"API Request Error: {str(e)}"
|
| 548 |
+
if hasattr(e, 'response') and e.response is not None:
|
| 549 |
+
try:
|
| 550 |
+
error_detail = e.response.json()
|
| 551 |
+
error_message += f" | Details: {error_detail}"
|
| 552 |
+
except json.JSONDecodeError:
|
| 553 |
+
error_message += f" | Status: {e.response.status_code} | Response: {e.response.text[:200]}"
|
| 554 |
+
else:
|
| 555 |
+
error_message += " (Network or connection issue)"
|
| 556 |
+
print(f"ERROR: {error_message}")
|
| 557 |
+
return (error_message, placeholder_image, "Stats N/A due to error", "Credits N/A due to error")
|
| 558 |
+
except Exception as e:
|
| 559 |
+
print(f"ERROR: Node Error: {str(e)}")
|
| 560 |
+
return (f"Node Error: {str(e)}", placeholder_image, "Stats N/A due to error", "Credits N/A due to error")
|
| 561 |
+
|
| 562 |
+
@staticmethod
|
| 563 |
+
def image_to_base64(image):
|
| 564 |
+
"""
|
| 565 |
+
Converts a ComfyUI IMAGE (torch.Tensor, BHWC, float 0-1)
|
| 566 |
+
into a base64-encoded PNG string.
|
| 567 |
+
"""
|
| 568 |
+
if not isinstance(image, torch.Tensor):
|
| 569 |
+
raise TypeError("Input 'image' is not a torch.Tensor")
|
| 570 |
+
|
| 571 |
+
# Remove batch dimension if present
|
| 572 |
+
if image.ndim == 4:
|
| 573 |
+
if image.shape[0] != 1:
|
| 574 |
+
print(f"Warning: Image batch size is {image.shape[0]}, using only the first image.")
|
| 575 |
+
image = image.squeeze(0) # Shape HWC
|
| 576 |
+
|
| 577 |
+
if image.ndim != 3:
|
| 578 |
+
raise ValueError(f"Unexpected image dimensions: {image.shape}. Expected HWC.")
|
| 579 |
+
|
| 580 |
+
# Convert float tensor (0-1) to numpy array (0-255, uint8)
|
| 581 |
+
image_np = image.cpu().numpy()
|
| 582 |
+
if image_np.dtype != np.uint8:
|
| 583 |
+
if image_np.min() < 0 or image_np.max() > 1:
|
| 584 |
+
print("Warning: Image tensor values outside [0, 1] range. Clamping.")
|
| 585 |
+
image_np = np.clip(image_np, 0, 1)
|
| 586 |
+
image_np = (image_np * 255).astype(np.uint8)
|
| 587 |
+
|
| 588 |
+
# Convert numpy array to PIL Image
|
| 589 |
+
pil_image = Image.fromarray(image_np, 'RGB') # Assuming RGB, adjust if needed
|
| 590 |
+
|
| 591 |
+
# Save PIL Image to a bytes buffer as PNG
|
| 592 |
+
buffered = io.BytesIO()
|
| 593 |
+
pil_image.save(buffered, format="PNG")
|
| 594 |
+
|
| 595 |
+
# Encode the bytes buffer to base64 string
|
| 596 |
+
return base64.b64encode(buffered.getvalue()).decode('utf-8')
|
| 597 |
+
|
| 598 |
+
@staticmethod
|
| 599 |
+
def base64_to_image(base64_str: str) -> torch.Tensor:
|
| 600 |
+
"""
|
| 601 |
+
Converts a base64 image string to a ComfyUI image tensor
|
| 602 |
+
Returns tensor in [1, H, W, 3] format with values in [0, 1]
|
| 603 |
+
"""
|
| 604 |
+
try:
|
| 605 |
+
# Decode base64 string to image
|
| 606 |
+
img_data = base64.b64decode(base64_str)
|
| 607 |
+
img = Image.open(io.BytesIO(img_data))
|
| 608 |
+
img = img.convert("RGB")
|
| 609 |
+
|
| 610 |
+
# Convert to numpy array and normalize to [0, 1]
|
| 611 |
+
img_array = np.array(img).astype(np.float32) / 255.0
|
| 612 |
+
|
| 613 |
+
# Add batch dimension: [1, H, W, 3]
|
| 614 |
+
img_tensor = torch.from_numpy(img_array).unsqueeze(0)
|
| 615 |
+
|
| 616 |
+
print(f"Successfully converted base64 to image tensor: {img_tensor.shape}")
|
| 617 |
+
return img_tensor
|
| 618 |
+
|
| 619 |
+
except Exception as e:
|
| 620 |
+
print(f"Error in base64_to_image: {e}")
|
| 621 |
+
# Return a small placeholder image instead of failing
|
| 622 |
+
return torch.zeros((1, 64, 64, 3), dtype=torch.float32)
|
| 623 |
+
|
| 624 |
+
@staticmethod
|
| 625 |
+
def count_tokens(text, model):
|
| 626 |
+
"""
|
| 627 |
+
Count tokens for a given text using tiktoken.
|
| 628 |
+
Uses model-specific encodings where possible, falls back to cl100k_base.
|
| 629 |
+
Handles potential errors during encoding.
|
| 630 |
+
"""
|
| 631 |
+
if not text or not isinstance(text, str):
|
| 632 |
+
return 0
|
| 633 |
+
|
| 634 |
+
# Strip any model modifiers like :floor, :nitro, :online
|
| 635 |
+
base_model = model.split(':')[0] if ':' in model else model
|
| 636 |
+
|
| 637 |
+
# Simplified mapping, cl100k_base is common for many recent models
|
| 638 |
+
encoding_name = "cl100k_base"
|
| 639 |
+
try:
|
| 640 |
+
# List known models/prefixes that definitely use cl100k_base
|
| 641 |
+
# Add others if known, but cl100k_base is a safe default for many
|
| 642 |
+
cl100k_models = [
|
| 643 |
+
"openai/gpt-4", "openai/gpt-3.5", "openai/gpt-4o",
|
| 644 |
+
"anthropic/claude",
|
| 645 |
+
"google/gemini",
|
| 646 |
+
"meta-llama/llama-2", "meta-llama/llama-3",
|
| 647 |
+
"mistralai/mistral", "mistralai/mixtral",
|
| 648 |
+
]
|
| 649 |
+
# Check if the base_model or its prefix matches known cl100k models
|
| 650 |
+
is_cl100k = any(base_model.startswith(prefix) for prefix in cl100k_models)
|
| 651 |
+
|
| 652 |
+
if is_cl100k:
|
| 653 |
+
encoding_name = "cl100k_base"
|
| 654 |
+
# else: # Add logic for other encodings if needed, e.g., p50k_base for older models
|
| 655 |
+
# pass # Stick with cl100k_base as default for now
|
| 656 |
+
|
| 657 |
+
encoding = tiktoken.get_encoding(encoding_name)
|
| 658 |
+
token_count = len(encoding.encode(text, disallowed_special=())) # Allow special tokens
|
| 659 |
+
return token_count
|
| 660 |
+
|
| 661 |
+
except Exception as e:
|
| 662 |
+
print(f"Warning: Tiktoken error for model '{model}' (base: '{base_model}', encoding: '{encoding_name}'): {e}. Falling back to estimation.")
|
| 663 |
+
# Fallback: Estimate tokens based on characters (rough approximation)
|
| 664 |
+
# Average ~4 chars per token is a common heuristic
|
| 665 |
+
return max(1, round(len(text) / 4))
|
| 666 |
+
|
| 667 |
+
|
| 668 |
+
@classmethod
|
| 669 |
+
def IS_CHANGED(cls, api_key, system_prompt, user_message_box, model,
|
| 670 |
+
web_search, cheapest, fastest, temperature, pdf_engine, chat_mode,
|
| 671 |
+
request_timeout=120, aspect_ratio="auto", image_resolution="1K", seed=0,
|
| 672 |
+
pdf_data=None, user_message_input=None, reasoning_effort="auto", **kwargs):
|
| 673 |
+
"""
|
| 674 |
+
Check if any input that affects the output has changed.
|
| 675 |
+
Includes hashing image and PDF data.
|
| 676 |
+
"""
|
| 677 |
+
# Hash image data if present - handle multiple images from kwargs
|
| 678 |
+
image_hashes = []
|
| 679 |
+
image_keys = sorted([k for k in kwargs.keys() if k.startswith('image_')],
|
| 680 |
+
key=lambda x: int(x.split('_')[1]))
|
| 681 |
+
|
| 682 |
+
for image_key in image_keys:
|
| 683 |
+
if kwargs[image_key] is not None:
|
| 684 |
+
image = kwargs[image_key]
|
| 685 |
+
if isinstance(image, torch.Tensor):
|
| 686 |
+
try:
|
| 687 |
+
hasher = hashlib.sha256()
|
| 688 |
+
hasher.update(image.cpu().numpy().tobytes())
|
| 689 |
+
image_hashes.append(hasher.hexdigest())
|
| 690 |
+
except Exception as e:
|
| 691 |
+
print(f"Warning: Could not hash {image_key} data for IS_CHANGED: {e}")
|
| 692 |
+
image_hashes.append(f"{image_key}_hashing_error")
|
| 693 |
+
else:
|
| 694 |
+
image_hashes.append(None)
|
| 695 |
+
|
| 696 |
+
|
| 697 |
+
# Hash PDF data if present and valid
|
| 698 |
+
pdf_hash = None
|
| 699 |
+
if pdf_data is not None and isinstance(pdf_data, dict) and "bytes" in pdf_data and isinstance(pdf_data["bytes"], bytes):
|
| 700 |
+
try:
|
| 701 |
+
hasher = hashlib.sha256()
|
| 702 |
+
hasher.update(pdf_data["bytes"])
|
| 703 |
+
pdf_hash = hasher.hexdigest()
|
| 704 |
+
# Optionally include filename in hash if it affects processing?
|
| 705 |
+
# if "filename" in pdf_data: hasher.update(pdf_data["filename"].encode())
|
| 706 |
+
except Exception as e:
|
| 707 |
+
print(f"Warning: Could not hash pdf data for IS_CHANGED: {e}")
|
| 708 |
+
pdf_hash = "pdf_hashing_error" # Use a placeholder on error
|
| 709 |
+
elif pdf_data is not None:
|
| 710 |
+
# Handle cases where pdf_data is present but not in the expected format
|
| 711 |
+
pdf_hash = "invalid_pdf_data_format"
|
| 712 |
+
|
| 713 |
+
|
| 714 |
+
# Ensure temperature is consistently represented (e.g., as float)
|
| 715 |
+
try:
|
| 716 |
+
temp_float = float(temperature) if isinstance(temperature, (str, int, float)) else 1.0
|
| 717 |
+
temp_float = max(0.0, min(2.0, temp_float))
|
| 718 |
+
except (ValueError, TypeError):
|
| 719 |
+
temp_float = 1.0
|
| 720 |
+
|
| 721 |
+
try:
|
| 722 |
+
timeout_int = int(request_timeout)
|
| 723 |
+
timeout_int = max(cls.min_request_timeout, min(cls.max_request_timeout, timeout_int))
|
| 724 |
+
except (ValueError, TypeError):
|
| 725 |
+
timeout_int = cls.default_request_timeout
|
| 726 |
+
|
| 727 |
+
validated_reasoning_effort = cls.validate_reasoning_effort(reasoning_effort)
|
| 728 |
+
|
| 729 |
+
|
| 730 |
+
# Combine all relevant inputs into a tuple for comparison
|
| 731 |
+
# Use primitive types where possible for reliable hashing/comparison
|
| 732 |
+
# Note: api_key here is the UI value only. Keys resolved from the LLM_KEY
|
| 733 |
+
# env var or openrouter_api_key.json are intentionally NOT part of the
|
| 734 |
+
# cache key — they're treated as user environment, not workflow inputs.
|
| 735 |
+
return (api_key, system_prompt, user_message_box, model,
|
| 736 |
+
web_search, cheapest, fastest, temp_float, pdf_engine, chat_mode,
|
| 737 |
+
timeout_int, aspect_ratio, image_resolution, seed, validated_reasoning_effort,
|
| 738 |
+
tuple(image_hashes), pdf_hash, user_message_input)
|
| 739 |
+
|
| 740 |
+
# Node class mappings
|
| 741 |
+
NODE_CLASS_MAPPINGS = {
|
| 742 |
+
"OpenRouterNode": OpenRouterNode
|
| 743 |
+
}
|
| 744 |
+
|
| 745 |
+
# Node display name mappings
|
| 746 |
+
NODE_DISPLAY_NAME_MAPPINGS = {
|
| 747 |
+
"OpenRouterNode": "OpenRouter LLM Node (Text/Multi-Image/PDF/Chat)" # Updated name
|
| 748 |
+
}
|
custom_nodes/openrouter_node/openrouter_api_key.json.example
ADDED
|
@@ -0,0 +1,3 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
{
|
| 2 |
+
"api_key": "your_openrouter_api_key_here"
|
| 3 |
+
}
|
custom_nodes/openrouter_node/openrouter_node_example.png
ADDED
|
Git LFS Details
|
custom_nodes/openrouter_node/pyproject.toml
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
[project]
|
| 2 |
+
name = "openrouter_node"
|
| 3 |
+
description = "A custom node for ComfyUI that allows you to interact with OpenRouter's chat/completion API, providing access to a wide range of models."
|
| 4 |
+
version = "1.0.2"
|
| 5 |
+
license = {file = "LICENSE"}
|
| 6 |
+
dependencies = ["requests", "tiktoken", "Pillow"]
|
| 7 |
+
|
| 8 |
+
[project.urls]
|
| 9 |
+
Repository = "https://github.com/gabe-init/ComfyUI-Openrouter_node"
|
| 10 |
+
# Used by Comfy Registry https://comfyregistry.org
|
| 11 |
+
|
| 12 |
+
[tool.comfy]
|
| 13 |
+
PublisherId = "gabe-init"
|
| 14 |
+
DisplayName = "ComfyUI-Openrouter_node"
|
| 15 |
+
Icon = ""
|
custom_nodes/openrouter_node/requirements.txt
ADDED
|
@@ -0,0 +1,2 @@
|
|
|
|
|
|
|
|
|
|
| 1 |
+
tiktoken # accurate LLM token counting
|
| 2 |
+
Pillow # image processing
|
custom_nodes/openrouter_node/tests/__init__.py
ADDED
|
@@ -0,0 +1 @@
|
|
|
|
|
|
|
| 1 |
+
|
custom_nodes/openrouter_node/tests/test_request_timeout.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import importlib.util
|
| 2 |
+
import sys
|
| 3 |
+
import types
|
| 4 |
+
import unittest
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from unittest.mock import Mock, patch
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
class FakeRequestException(Exception):
|
| 10 |
+
def __init__(self, message="", response=None):
|
| 11 |
+
super().__init__(message)
|
| 12 |
+
self.response = response
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
class FakeTimeout(FakeRequestException):
|
| 16 |
+
pass
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
def install_dependency_stubs():
|
| 20 |
+
requests_module = types.ModuleType("requests")
|
| 21 |
+
exceptions = types.SimpleNamespace(
|
| 22 |
+
RequestException=FakeRequestException,
|
| 23 |
+
Timeout=FakeTimeout,
|
| 24 |
+
)
|
| 25 |
+
requests_module.exceptions = exceptions
|
| 26 |
+
requests_module.get = Mock()
|
| 27 |
+
requests_module.post = Mock()
|
| 28 |
+
sys.modules["requests"] = requests_module
|
| 29 |
+
|
| 30 |
+
torch_module = types.ModuleType("torch")
|
| 31 |
+
torch_module.float32 = "float32"
|
| 32 |
+
torch_module.Tensor = type("Tensor", (), {})
|
| 33 |
+
torch_module.zeros = Mock(return_value="placeholder-image")
|
| 34 |
+
sys.modules["torch"] = torch_module
|
| 35 |
+
|
| 36 |
+
tiktoken_module = types.ModuleType("tiktoken")
|
| 37 |
+
tiktoken_module.get_encoding = Mock()
|
| 38 |
+
sys.modules["tiktoken"] = tiktoken_module
|
| 39 |
+
|
| 40 |
+
pil_module = types.ModuleType("PIL")
|
| 41 |
+
image_module = types.ModuleType("PIL.Image")
|
| 42 |
+
image_module.open = Mock()
|
| 43 |
+
image_module.fromarray = Mock()
|
| 44 |
+
pil_module.Image = image_module
|
| 45 |
+
sys.modules["PIL"] = pil_module
|
| 46 |
+
sys.modules["PIL.Image"] = image_module
|
| 47 |
+
|
| 48 |
+
sys.modules.setdefault("numpy", types.ModuleType("numpy"))
|
| 49 |
+
|
| 50 |
+
|
| 51 |
+
def load_node_module():
|
| 52 |
+
install_dependency_stubs()
|
| 53 |
+
root = Path(__file__).resolve().parents[1]
|
| 54 |
+
package_name = "comfy_openrouter_node_test"
|
| 55 |
+
|
| 56 |
+
package = types.ModuleType(package_name)
|
| 57 |
+
package.__path__ = [str(root)]
|
| 58 |
+
sys.modules[package_name] = package
|
| 59 |
+
|
| 60 |
+
spec = importlib.util.spec_from_file_location(
|
| 61 |
+
f"{package_name}.node",
|
| 62 |
+
root / "node.py",
|
| 63 |
+
submodule_search_locations=[str(root)],
|
| 64 |
+
)
|
| 65 |
+
module = importlib.util.module_from_spec(spec)
|
| 66 |
+
sys.modules[spec.name] = module
|
| 67 |
+
spec.loader.exec_module(module)
|
| 68 |
+
return module
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
class RequestTimeoutTests(unittest.TestCase):
|
| 72 |
+
def setUp(self):
|
| 73 |
+
self.node_module = load_node_module()
|
| 74 |
+
self.node = self.node_module.OpenRouterNode()
|
| 75 |
+
self.node.fetch_credits = Mock(return_value="Remaining: $1.000")
|
| 76 |
+
self.node.count_tokens = Mock(return_value=1)
|
| 77 |
+
|
| 78 |
+
def call_generate_response(self, request_timeout=120, reasoning_effort="auto"):
|
| 79 |
+
return self.node.generate_response(
|
| 80 |
+
api_key="test-key",
|
| 81 |
+
system_prompt="system",
|
| 82 |
+
user_message_box="hello",
|
| 83 |
+
model="openai/gpt-4o",
|
| 84 |
+
web_search=False,
|
| 85 |
+
cheapest=False,
|
| 86 |
+
fastest=False,
|
| 87 |
+
temperature=1.0,
|
| 88 |
+
pdf_engine="auto",
|
| 89 |
+
chat_mode=False,
|
| 90 |
+
request_timeout=request_timeout,
|
| 91 |
+
reasoning_effort=reasoning_effort,
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def test_main_openrouter_request_uses_configured_timeout(self):
|
| 95 |
+
response = Mock()
|
| 96 |
+
response.raise_for_status = Mock()
|
| 97 |
+
response.json.return_value = {
|
| 98 |
+
"choices": [{"message": {"content": "done"}}],
|
| 99 |
+
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
|
| 100 |
+
}
|
| 101 |
+
self.node_module.requests.post.return_value = response
|
| 102 |
+
|
| 103 |
+
result = self.call_generate_response(request_timeout=45)
|
| 104 |
+
|
| 105 |
+
self.assertEqual(result[0], "done")
|
| 106 |
+
self.node_module.requests.post.assert_called_once()
|
| 107 |
+
self.assertEqual(self.node_module.requests.post.call_args.kwargs["timeout"], 45)
|
| 108 |
+
|
| 109 |
+
def test_default_reasoning_effort_omits_reasoning_override(self):
|
| 110 |
+
response = Mock()
|
| 111 |
+
response.raise_for_status = Mock()
|
| 112 |
+
response.json.return_value = {
|
| 113 |
+
"choices": [{"message": {"content": "done"}}],
|
| 114 |
+
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
|
| 115 |
+
}
|
| 116 |
+
self.node_module.requests.post.return_value = response
|
| 117 |
+
|
| 118 |
+
result = self.call_generate_response()
|
| 119 |
+
|
| 120 |
+
self.assertEqual(result[0], "done")
|
| 121 |
+
payload = self.node_module.requests.post.call_args.kwargs["json"]
|
| 122 |
+
self.assertNotIn("reasoning", payload)
|
| 123 |
+
|
| 124 |
+
def test_explicit_reasoning_effort_is_sent(self):
|
| 125 |
+
response = Mock()
|
| 126 |
+
response.raise_for_status = Mock()
|
| 127 |
+
response.json.return_value = {
|
| 128 |
+
"choices": [{"message": {"content": "done"}}],
|
| 129 |
+
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
|
| 130 |
+
}
|
| 131 |
+
self.node_module.requests.post.return_value = response
|
| 132 |
+
|
| 133 |
+
result = self.call_generate_response(reasoning_effort="high")
|
| 134 |
+
|
| 135 |
+
self.assertEqual(result[0], "done")
|
| 136 |
+
payload = self.node_module.requests.post.call_args.kwargs["json"]
|
| 137 |
+
self.assertEqual(payload["reasoning"], {"effort": "high"})
|
| 138 |
+
|
| 139 |
+
def test_invalid_reasoning_effort_falls_back_to_auto(self):
|
| 140 |
+
response = Mock()
|
| 141 |
+
response.raise_for_status = Mock()
|
| 142 |
+
response.json.return_value = {
|
| 143 |
+
"choices": [{"message": {"content": "done"}}],
|
| 144 |
+
"usage": {"prompt_tokens": 2, "completion_tokens": 3},
|
| 145 |
+
}
|
| 146 |
+
self.node_module.requests.post.return_value = response
|
| 147 |
+
|
| 148 |
+
result = self.call_generate_response(reasoning_effort="unsupported")
|
| 149 |
+
|
| 150 |
+
self.assertEqual(result[0], "done")
|
| 151 |
+
payload = self.node_module.requests.post.call_args.kwargs["json"]
|
| 152 |
+
self.assertNotIn("reasoning", payload)
|
| 153 |
+
|
| 154 |
+
def test_is_changed_includes_reasoning_effort(self):
|
| 155 |
+
base_args = dict(
|
| 156 |
+
api_key="test-key",
|
| 157 |
+
system_prompt="system",
|
| 158 |
+
user_message_box="hello",
|
| 159 |
+
model="openai/gpt-4o",
|
| 160 |
+
web_search=False,
|
| 161 |
+
cheapest=False,
|
| 162 |
+
fastest=False,
|
| 163 |
+
temperature=1.0,
|
| 164 |
+
pdf_engine="auto",
|
| 165 |
+
chat_mode=False,
|
| 166 |
+
request_timeout=120,
|
| 167 |
+
)
|
| 168 |
+
|
| 169 |
+
low_key = self.node_module.OpenRouterNode.IS_CHANGED(**base_args, reasoning_effort="low")
|
| 170 |
+
high_key = self.node_module.OpenRouterNode.IS_CHANGED(**base_args, reasoning_effort="high")
|
| 171 |
+
|
| 172 |
+
self.assertNotEqual(low_key, high_key)
|
| 173 |
+
|
| 174 |
+
def test_timeout_exception_returns_clear_error(self):
|
| 175 |
+
self.node_module.requests.post.side_effect = FakeTimeout("request timed out")
|
| 176 |
+
|
| 177 |
+
result = self.call_generate_response(request_timeout=2)
|
| 178 |
+
|
| 179 |
+
self.assertIn("API Request Error", result[0])
|
| 180 |
+
self.assertIn("request timed out", result[0])
|
| 181 |
+
self.assertEqual(result[2], "Stats N/A due to error")
|
| 182 |
+
self.assertEqual(result[3], "Credits N/A due to error")
|
| 183 |
+
|
| 184 |
+
def test_fetch_credits_uses_configured_timeout(self):
|
| 185 |
+
self.node.fetch_credits = self.node_module.OpenRouterNode().fetch_credits
|
| 186 |
+
response = Mock()
|
| 187 |
+
response.raise_for_status = Mock()
|
| 188 |
+
response.json.return_value = {
|
| 189 |
+
"data": {
|
| 190 |
+
"total_credits": 10.0,
|
| 191 |
+
"total_usage": 3.25,
|
| 192 |
+
}
|
| 193 |
+
}
|
| 194 |
+
self.node_module.requests.get.return_value = response
|
| 195 |
+
|
| 196 |
+
credits = self.node.fetch_credits("test-key", timeout=45)
|
| 197 |
+
|
| 198 |
+
self.assertEqual(credits, "Remaining: $6.750")
|
| 199 |
+
self.node_module.requests.get.assert_called_once()
|
| 200 |
+
self.assertEqual(self.node_module.requests.get.call_args.kwargs["timeout"], 45)
|
| 201 |
+
|
| 202 |
+
|
| 203 |
+
if __name__ == "__main__":
|
| 204 |
+
unittest.main()
|
custom_nodes/openrouter_node/web/openrouter_dynamic_inputs.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
/**
|
| 2 |
+
* Dynamic image inputs for OpenRouter Node
|
| 3 |
+
* Based on cozy_ex_dynamic pattern for clean dynamic inputs
|
| 4 |
+
*/
|
| 5 |
+
|
| 6 |
+
import { app } from "../../../scripts/app.js"
|
| 7 |
+
|
| 8 |
+
const TypeSlot = {
|
| 9 |
+
Input: 1,
|
| 10 |
+
Output: 2,
|
| 11 |
+
};
|
| 12 |
+
|
| 13 |
+
const TypeSlotEvent = {
|
| 14 |
+
Connect: true,
|
| 15 |
+
Disconnect: false,
|
| 16 |
+
};
|
| 17 |
+
|
| 18 |
+
const NODE_ID = "OpenRouterNode";
|
| 19 |
+
const PREFIX = "image";
|
| 20 |
+
const TYPE = "IMAGE";
|
| 21 |
+
|
| 22 |
+
app.registerExtension({
|
| 23 |
+
name: 'OpenRouter.DynamicImageInputs',
|
| 24 |
+
async beforeRegisterNodeDef(nodeType, nodeData, app) {
|
| 25 |
+
// Skip if not our node
|
| 26 |
+
if (nodeData.name !== NODE_ID) {
|
| 27 |
+
return
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
const onNodeCreated = nodeType.prototype.onNodeCreated;
|
| 31 |
+
nodeType.prototype.onNodeCreated = function () {
|
| 32 |
+
const me = onNodeCreated?.apply(this);
|
| 33 |
+
|
| 34 |
+
// Find api_key widget and update label with security warning
|
| 35 |
+
const apiKeyWidget = this.widgets?.find(w => w.name === "api_key");
|
| 36 |
+
if (apiKeyWidget) {
|
| 37 |
+
apiKeyWidget.label = "api_key (Leave blank for secure loading)";
|
| 38 |
+
}
|
| 39 |
+
|
| 40 |
+
// Start with a new dynamic input - exactly like cozy example
|
| 41 |
+
this.addInput(PREFIX, TYPE);
|
| 42 |
+
|
| 43 |
+
// Ensure the new slot has proper appearance
|
| 44 |
+
const slot = this.inputs[this.inputs.length - 1];
|
| 45 |
+
if (slot) {
|
| 46 |
+
slot.color_off = "#666";
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
return me;
|
| 50 |
+
}
|
| 51 |
+
|
| 52 |
+
const onConnectionsChange = nodeType.prototype.onConnectionsChange
|
| 53 |
+
nodeType.prototype.onConnectionsChange = function (slotType, slot_idx, event, link_info, node_slot) {
|
| 54 |
+
const me = onConnectionsChange?.apply(this, arguments);
|
| 55 |
+
|
| 56 |
+
if (slotType === TypeSlot.Input) {
|
| 57 |
+
// Only process image inputs
|
| 58 |
+
if (node_slot && !node_slot.name.startsWith(PREFIX)) {
|
| 59 |
+
return me;
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
if (link_info && event === TypeSlotEvent.Connect) {
|
| 63 |
+
// Get the parent (left side node) from the link
|
| 64 |
+
const fromNode = this.graph._nodes.find(
|
| 65 |
+
(otherNode) => otherNode.id == link_info.origin_id
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
if (fromNode) {
|
| 69 |
+
// Make sure there is a parent for the link
|
| 70 |
+
const parent_link = fromNode.outputs[link_info.origin_slot];
|
| 71 |
+
if (parent_link) {
|
| 72 |
+
node_slot.type = parent_link.type;
|
| 73 |
+
node_slot.name = `${PREFIX}_`;
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
} else if (event === TypeSlotEvent.Disconnect) {
|
| 77 |
+
// Don't remove the slot immediately, let the cleanup below handle it
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
// Track each slot name so we can index the uniques
|
| 81 |
+
let idx = 0;
|
| 82 |
+
let slot_tracker = {};
|
| 83 |
+
let toRemove = [];
|
| 84 |
+
|
| 85 |
+
for(const slot of this.inputs) {
|
| 86 |
+
// Skip non-image inputs
|
| 87 |
+
if (!slot.name.startsWith(PREFIX)) {
|
| 88 |
+
idx += 1;
|
| 89 |
+
continue;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
// Mark empty image slots for removal (except the last one)
|
| 93 |
+
if (slot.link === null && idx < this.inputs.length - 1) {
|
| 94 |
+
toRemove.push(idx);
|
| 95 |
+
} else if (slot.link !== null) {
|
| 96 |
+
// Connected slot - update its name with proper index
|
| 97 |
+
const name = slot.name.split('_')[0];
|
| 98 |
+
let count = (slot_tracker[name] || 0) + 1;
|
| 99 |
+
slot_tracker[name] = count;
|
| 100 |
+
slot.name = `${name}_${count}`;
|
| 101 |
+
}
|
| 102 |
+
idx += 1;
|
| 103 |
+
}
|
| 104 |
+
|
| 105 |
+
// Remove empty slots from highest index to lowest
|
| 106 |
+
toRemove.reverse();
|
| 107 |
+
for(const removeIdx of toRemove) {
|
| 108 |
+
this.removeInput(removeIdx);
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
// Check if the last input is an image input
|
| 112 |
+
let lastInput = null;
|
| 113 |
+
for (let i = this.inputs.length - 1; i >= 0; i--) {
|
| 114 |
+
if (this.inputs[i].name.startsWith(PREFIX)) {
|
| 115 |
+
lastInput = this.inputs[i];
|
| 116 |
+
break;
|
| 117 |
+
}
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
// If there's no empty image slot at the end, or no image slots at all, add one
|
| 121 |
+
if (!lastInput || lastInput.link !== null) {
|
| 122 |
+
this.addInput(PREFIX, TYPE);
|
| 123 |
+
// Set the unconnected slot to appear gray
|
| 124 |
+
const newSlot = this.inputs[this.inputs.length - 1];
|
| 125 |
+
if (newSlot) {
|
| 126 |
+
newSlot.color_off = "#666";
|
| 127 |
+
}
|
| 128 |
+
}
|
| 129 |
+
|
| 130 |
+
// Force the node to resize itself for the new/deleted connections
|
| 131 |
+
this?.graph?.setDirtyCanvas(true);
|
| 132 |
+
return me;
|
| 133 |
+
}
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
return nodeType;
|
| 137 |
+
},
|
| 138 |
+
})
|