saik0s commited on
Commit
d7ebf46
·
verified ·
1 Parent(s): 6f5b734

Upload folder using huggingface_hub

Browse files
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
+ ![Nano Banana Example](./nano_banana_example.jpeg)
28
+
29
+ ![OpenRouter Node Example](https://github.com/gabe-init/ComfyUI-Openrouter_node/blob/main/openrouter_node_example.png?raw=true)
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
+ ![Multiple Images Example](https://github.com/user-attachments/assets/09f2478c-c4f8-46f0-b79e-e4766f020119)
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

  • SHA256: f401a40b642f3ca12be037af422484a1459b67dedc40aed0835d8b8c707747d7
  • Pointer size: 131 Bytes
  • Size of remote file: 470 kB
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

  • SHA256: 69b5e4180eac126371d7b3f1bbd80b6faca50f0fe24f01331c67d37ab283a4a5
  • Pointer size: 131 Bytes
  • Size of remote file: 168 kB
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
+ })