From 118a9d47013ab7315ac5cdb2896a4b4c464186ea Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Fri, 19 Dec 2025 14:41:54 -0600
Subject: [PATCH 1/9] Add multi-provider AI support with Free/Pro tier system
---
.../services/ai_service_factory.py | 32 +-
.../block_manager/services/gemini_service.py | 6 +-
.../block_manager/services/openai_service.py | 475 ++++++++++++++++++
project/block_manager/views/chat_views.py | 16 +-
.../frontend/src/components/ApiKeyModal.tsx | 330 +++++++++---
project/frontend/src/components/ChatBot.tsx | 17 +-
project/frontend/src/components/Header.tsx | 8 +-
.../frontend/src/contexts/ApiKeyContext.tsx | 48 +-
project/frontend/src/lib/api.ts | 10 +
project/requirements.txt | 1 +
10 files changed, 845 insertions(+), 98 deletions(-)
create mode 100644 project/block_manager/services/openai_service.py
diff --git a/project/block_manager/services/ai_service_factory.py b/project/block_manager/services/ai_service_factory.py
index 6245ec8..ee574bf 100644
--- a/project/block_manager/services/ai_service_factory.py
+++ b/project/block_manager/services/ai_service_factory.py
@@ -1,11 +1,12 @@
"""
-AI Service Factory - Provider selection for Gemini or Claude with BYOK support.
+AI Service Factory - Provider selection for Gemini, Claude, or OpenAI with BYOK support.
"""
import os
from typing import Union, Optional
from django.conf import settings
from block_manager.services.gemini_service import GeminiChatService
from block_manager.services.claude_service import ClaudeChatService
+from block_manager.services.openai_service import OpenAIChatService
class AIServiceFactory:
@@ -25,8 +26,10 @@ def requires_user_api_key() -> bool:
@staticmethod
def create_service(
gemini_api_key: Optional[str] = None,
- anthropic_api_key: Optional[str] = None
- ) -> Union[GeminiChatService, ClaudeChatService]:
+ anthropic_api_key: Optional[str] = None,
+ openai_api_key: Optional[str] = None,
+ active_provider: Optional[str] = None
+ ) -> Union[GeminiChatService, ClaudeChatService, OpenAIChatService]:
"""
Create and return the configured AI service.
@@ -36,14 +39,17 @@ def create_service(
Args:
gemini_api_key: User-provided Gemini API key (PROD mode only)
anthropic_api_key: User-provided Anthropic API key (PROD mode only)
+ openai_api_key: User-provided OpenAI API key (PROD mode only)
+ active_provider: User's chosen provider ('gemini', 'claude', or 'openai')
Returns:
- GeminiChatService or ClaudeChatService based on AI_PROVIDER
+ GeminiChatService, ClaudeChatService, or OpenAIChatService based on active_provider
Raises:
ValueError: If API keys are missing or provider is invalid
"""
- provider = os.getenv('AI_PROVIDER', 'gemini').lower()
+ # Use active_provider if provided, otherwise fall back to AI_PROVIDER env var
+ provider = (active_provider or os.getenv('AI_PROVIDER', 'gemini')).lower()
requires_user_key = AIServiceFactory.requires_user_api_key()
if provider == 'gemini':
@@ -65,9 +71,19 @@ def create_service(
else:
# DEV mode: use server-side key
return ClaudeChatService()
+
+ elif provider == 'openai':
+ if requires_user_key:
+ # PROD mode: require user-provided key
+ if not openai_api_key:
+ raise ValueError("OpenAI API key is required. Please provide your API key.")
+ return OpenAIChatService(api_key=openai_api_key)
+ else:
+ # DEV mode: use server-side key
+ return OpenAIChatService()
else:
raise ValueError(
- f"Invalid AI_PROVIDER: '{provider}'. Must be 'gemini' or 'claude'."
+ f"Invalid provider: '{provider}'. Must be 'gemini', 'claude', or 'openai'."
)
@staticmethod
@@ -76,9 +92,11 @@ def get_provider_name() -> str:
Get the name of the current AI provider.
Returns:
- 'Gemini' or 'Claude'
+ 'Gemini', 'Claude', or 'OpenAI'
"""
provider = os.getenv('AI_PROVIDER', 'gemini').lower()
+ if provider == 'openai':
+ return 'OpenAI'
return provider.capitalize()
@staticmethod
diff --git a/project/block_manager/services/gemini_service.py b/project/block_manager/services/gemini_service.py
index 07efb31..1b54470 100644
--- a/project/block_manager/services/gemini_service.py
+++ b/project/block_manager/services/gemini_service.py
@@ -30,9 +30,9 @@ def __init__(self, api_key: Optional[str] = None):
raise ValueError("GEMINI_API_KEY environment variable is not set")
genai.configure(api_key=final_api_key)
- # Use gemini-2.0-flash-lite - best free tier availability in 2025
- # (gemini-1.5-* deprecated April 2025, gemini-2.5-* severely limited)
- self.model = genai.GenerativeModel('gemini-2.0-flash-lite')
+ # Use gemini-2.5-flash - best free tier availability (Dec 2025)
+ # (gemini-2.0-* models have quota limit: 0 as of Dec 2025)
+ self.model = genai.GenerativeModel('gemini-2.5-flash')
def _format_workflow_context(self, workflow_state: Optional[Dict[str, Any]]) -> str:
"""Format workflow state into a readable context for the AI."""
diff --git a/project/block_manager/services/openai_service.py b/project/block_manager/services/openai_service.py
new file mode 100644
index 0000000..d394814
--- /dev/null
+++ b/project/block_manager/services/openai_service.py
@@ -0,0 +1,475 @@
+"""
+OpenAI Service for chat functionality and workflow modifications.
+"""
+import openai
+import json
+import os
+from typing import List, Dict, Any, Optional
+from django.conf import settings
+from django.core.files.uploadedfile import UploadedFile
+
+
+class OpenAIChatService:
+ """Service to handle OpenAI chat interactions with workflow context."""
+
+ def __init__(self, api_key: Optional[str] = None):
+ """
+ Initialize OpenAI with API key.
+
+ Args:
+ api_key: Optional API key for BYOK mode. If None, reads from environment.
+ """
+ if api_key:
+ # BYOK mode - use provided key
+ final_api_key = api_key
+ else:
+ # DEV mode - use environment variable
+ final_api_key = os.getenv('OPENAI_API_KEY')
+ if not final_api_key:
+ raise ValueError("OPENAI_API_KEY environment variable is not set")
+
+ self.client = openai.OpenAI(api_key=final_api_key)
+ # Use GPT-4o-mini for cost-effectiveness
+ self.model = 'gpt-4o-mini'
+
+ def _format_workflow_context(self, workflow_state: Optional[Dict[str, Any]]) -> str:
+ """Format workflow state into a readable context for the AI."""
+ if not workflow_state:
+ return "No workflow is currently loaded."
+
+ nodes = workflow_state.get('nodes', [])
+ edges = workflow_state.get('edges', [])
+
+ context_parts = [
+ "=== Current Workflow State ===",
+ f"Total nodes: {len(nodes)}",
+ f"Total connections: {len(edges)}",
+ "",
+ "Nodes in the workflow:"
+ ]
+
+ for node in nodes:
+ node_id = node.get('id', 'unknown')
+ node_type = node.get('type', 'unknown')
+ position = node.get('position', {})
+ data = node.get('data', {})
+ label = data.get('label', 'Unlabeled')
+ node_type_name = data.get('nodeType', data.get('blockType', 'unknown'))
+ config = data.get('config', {})
+
+ # Format node info with position
+ pos_str = f"Position: x={position.get('x', 0)}, y={position.get('y', 0)}"
+ context_parts.append(f" - {label} (ID: '{node_id}', NodeType: '{node_type_name}', {pos_str})")
+ if config:
+ config_str = ', '.join([f"{k}={v}" for k, v in config.items() if k != 'nodeType'])
+ if config_str:
+ context_parts.append(f" Config: {config_str}")
+
+ if edges:
+ context_parts.append("")
+ context_parts.append("Connections:")
+ for edge in edges:
+ edge_id = edge.get('id', '?')
+ source = edge.get('source', '?')
+ target = edge.get('target', '?')
+ source_label = next((n.get('data', {}).get('label', source)
+ for n in nodes if n.get('id') == source), source)
+ target_label = next((n.get('data', {}).get('label', target)
+ for n in nodes if n.get('id') == target), target)
+ context_parts.append(f" - {source_label} → {target_label} (Edge ID: '{edge_id}', Source: '{source}', Target: '{target}')")
+
+ return "\n".join(context_parts)
+
+ def _build_system_prompt(self, modification_mode: bool, workflow_state: Optional[Dict[str, Any]]) -> str:
+ """Build system prompt based on mode and workflow context."""
+ base_prompt = """You are an AI assistant for VisionForge, a visual neural network architecture builder.
+
+VisionForge allows users to create deep learning models by connecting nodes (blocks) in a visual workflow.
+
+=== AVAILABLE NODE TYPES AND THEIR CONFIGURATION SCHEMAS ===
+
+INPUT NODES:
+- "input": {"shape": "[1, 3, 224, 224]", "label": "Input"}
+ - shape: tensor dimensions as string (required)
+ - label: custom label (optional)
+
+- "dataloader": {"dataset_name": "string", "batch_size": 32, "shuffle": true}
+
+CONVOLUTIONAL LAYERS:
+- "conv2d": {"out_channels": 64, "kernel_size": 3, "stride": 1, "padding": 1, "dilation": 1}
+ - out_channels: REQUIRED (number of output channels)
+ - kernel_size, stride, padding, dilation: optional (defaults shown)
+
+- "conv1d": {"out_channels": 64, "kernel_size": 3, "stride": 1, "padding": 0}
+- "conv3d": {"out_channels": 64, "kernel_size": 3, "stride": 1, "padding": 0}
+
+LINEAR LAYERS:
+- "linear": {"out_features": 10}
+ - out_features: REQUIRED (output dimension)
+
+- "embedding": {"num_embeddings": 1000, "embedding_dim": 128}
+ - Both fields REQUIRED
+
+ACTIVATION FUNCTIONS (no config needed, use empty object {}):
+- "relu", "softmax", "sigmoid", "tanh", "leakyrelu": {}
+
+POOLING LAYERS:
+- "maxpool": {"kernel_size": 2, "stride": 2, "padding": 0}
+- "avgpool": {"kernel_size": 2, "stride": 2, "padding": 0}
+- "adaptiveavgpool": {"output_size": "[1, 1]"}
+
+NORMALIZATION:
+- "batchnorm": {"num_features": 64}
+ - num_features: REQUIRED (must match input channels)
+
+- "dropout": {"p": 0.5}
+ - p: dropout probability (default 0.5)
+
+MERGE OPERATIONS (no config needed):
+- "concat": {}
+- "add": {}
+
+UTILITY:
+- "flatten": {}
+- "attention": {"embed_dim": 512, "num_heads": 8}
+- "output": {} (no config)
+- "loss": {"loss_type": "CrossEntropyLoss"}
+
+CRITICAL RULES:
+1. ALWAYS provide REQUIRED fields (marked above)
+2. Use exact nodeType names in LOWERCASE: "input", "conv2d", "linear", "output", etc.
+3. For conv2d, NEVER use "in_channels" - it's inferred from connections
+4. Use empty config {} for nodes that don't need configuration
+5. Provide reasonable defaults for optional fields
+"""
+
+ if modification_mode:
+ mode_prompt = """
+MODIFICATION MODE ENABLED:
+You MUST provide actionable workflow modifications when users ask you to make changes.
+
+CRITICAL INSTRUCTION - BE PRECISE AND MINIMAL:
+- ONLY add/modify/remove what the user EXPLICITLY requests
+- DO NOT be creative or add extra nodes unless asked
+- Follow the user's exact specifications to the letter
+- Provide a brief natural language response
+- Include ONLY the JSON blocks for what was requested
+
+Examples of CORRECT responses:
+- User: "Add 2 input nodes" → Provide EXACTLY 2 add_node blocks for input, NOTHING MORE
+- User: "Add a Conv2D layer" → Provide EXACTLY 1 add_node block for conv2d, NOTHING MORE
+- User: "input connects to conv2d connects to output" → Provide EXACTLY 3 add_node blocks (input, conv2d, output), mention connections will be added after nodes exist
+- User: "Remove dropout" → Provide EXACTLY 1 remove_node block
+- User: "Duplicate the ReLU" → Provide EXACTLY 1 duplicate_node block
+- User: "Change kernel to 5" → Provide EXACTLY 1 modify_node block
+- User: "Move conv2d down" → Provide EXACTLY 1 modify_node block with position
+- User: "Rename input to 'Image Data'" → Provide EXACTLY 1 modify_node block with label
+
+MANDATORY FORMAT for each modification (include the ```json code fences):
+
+FOR ADDING NODES:
+```json
+{
+ "action": "add_node",
+ "details": {
+ "nodeType": "input",
+ "config": {"shape": "[1, 3, 224, 224]"},
+ "position": {"x": 100, "y": 100}
+ },
+ "explanation": "Adding an Input node for image data"
+}
+```
+
+FOR REMOVING NODES:
+Use the exact node ID from the workflow context:
+```json
+{
+ "action": "remove_node",
+ "details": {
+ "id": "conv-1234567890"
+ },
+ "explanation": "Removing the Conv2D layer"
+}
+```
+
+FOR DUPLICATING NODES:
+Creates a copy of an existing node with the same configuration:
+```json
+{
+ "action": "duplicate_node",
+ "details": {
+ "id": "relu-1234567890"
+ },
+ "explanation": "Duplicating the ReLU activation"
+}
+```
+
+FOR MODIFYING NODES:
+Use modify_node to update node configuration, position, or label:
+- To update config: include "id" and "config" fields
+- To move a node: include "id" and "position" fields
+- To rename a node: include "id" and "label" fields
+- You can update multiple properties at once
+
+Example (updating config):
+```json
+{
+ "action": "modify_node",
+ "details": {
+ "id": "conv-1234567890",
+ "config": {"kernel_size": 5, "padding": 2}
+ },
+ "explanation": "Changing kernel size to 5 and padding to 2"
+}
+```
+
+Example (moving node):
+```json
+{
+ "action": "modify_node",
+ "details": {
+ "id": "relu-1234567890",
+ "position": {"x": 350, "y": 200}
+ },
+ "explanation": "Moving ReLU node down"
+}
+```
+
+Example (renaming node):
+```json
+{
+ "action": "modify_node",
+ "details": {
+ "id": "conv-1234567890",
+ "label": "Feature Extractor"
+ },
+ "explanation": "Renaming Conv2D layer to 'Feature Extractor'"
+}
+```
+
+FOR CONNECTIONS (two-step process):
+STEP 1: When user requests connected nodes (e.g., "A connects to B connects to C"):
+ - First add the nodes they requested (A, B, C)
+ - Tell user: "Please apply these nodes first, then I can connect them"
+
+STEP 2: After nodes exist in the workflow context, create connections:
+ - Use the exact node IDs shown in the workflow context
+
+Example (adding connection):
+```json
+{
+ "action": "add_connection",
+ "details": {
+ "source": "node-1234567890",
+ "target": "node-9876543210",
+ "sourceHandle": null,
+ "targetHandle": null
+ },
+ "explanation": "Connecting Input to Conv2D"
+}
+```
+
+Example (removing connection by ID):
+```json
+{
+ "action": "remove_connection",
+ "details": {
+ "id": "edge-1234567890"
+ },
+ "explanation": "Removing connection between nodes"
+}
+```
+
+Example (removing connection by source/target):
+```json
+{
+ "action": "remove_connection",
+ "details": {
+ "source": "input-1234567890",
+ "target": "conv-9876543210"
+ },
+ "explanation": "Removing connection from Input to Conv2D"
+}
+```
+
+IMPORTANT RULES:
+- ALWAYS wrap each modification in ```json ``` code fences
+- Use exact node type names in LOWERCASE: input, dataloader, conv2d, linear, relu, etc.
+- For node operations (remove, duplicate, modify), ALWAYS use node IDs from the current workflow context
+- For connections, ONLY use node IDs from the current workflow context
+- You CANNOT connect nodes that don't exist yet
+- When modifying nodes, use "id" field (not "nodeId") in details
+- When removing connections, use "id" field or provide both "source" and "target"
+- Provide only what user explicitly requests
+- User sees "Apply Change" buttons for each modification
+
+SUPPORTED ACTIONS:
+1. add_node - Add a new node to the workflow
+2. remove_node - Remove an existing node (requires "id")
+3. duplicate_node - Duplicate an existing node (requires "id")
+4. modify_node - Update node config/position/label (requires "id" plus one or more: "config", "position", "label")
+5. add_connection - Connect two existing nodes (requires "source" and "target")
+6. remove_connection - Remove a connection (requires "id" OR both "source" and "target")
+"""
+ else:
+ mode_prompt = """
+Q&A MODE:
+You are in question-answering mode. Help users understand their workflow, explain concepts, and provide guidance.
+You cannot modify the workflow in this mode. If users want to make changes, suggest they enable modification mode.
+"""
+
+ workflow_context = self._format_workflow_context(workflow_state)
+
+ return f"{base_prompt}\n{mode_prompt}\n{workflow_context}"
+
+ def _format_chat_history(self, history: List[Dict[str, str]]) -> List[Dict[str, Any]]:
+ """Convert chat history to OpenAI format."""
+ formatted_history = []
+
+ for message in history:
+ role = message.get('role', 'user')
+ content = message.get('content', '')
+
+ # OpenAI uses 'user' and 'assistant' roles
+ formatted_history.append({
+ 'role': role,
+ 'content': content
+ })
+
+ return formatted_history
+
+ def chat(
+ self,
+ message: str,
+ history: List[Dict[str, str]],
+ modification_mode: bool = False,
+ workflow_state: Optional[Dict[str, Any]] = None,
+ **kwargs
+ ) -> Dict[str, Any]:
+ """
+ Send a chat message and get a response from OpenAI.
+
+ Args:
+ message: User's message
+ history: Previous chat messages [{'role': 'user'|'assistant', 'content': '...'}]
+ modification_mode: Whether workflow modification is enabled
+ workflow_state: Current workflow state (nodes and edges)
+
+ Returns:
+ {
+ 'response': str,
+ 'modifications': Optional[List[Dict]] - suggested workflow changes if any
+ }
+ """
+ try:
+ # Build system context
+ system_prompt = self._build_system_prompt(modification_mode, workflow_state)
+
+ # Format history for OpenAI
+ formatted_history = self._format_chat_history(history)
+
+ # Build messages array
+ messages = [
+ {'role': 'system', 'content': system_prompt}
+ ] + formatted_history + [
+ {'role': 'user', 'content': message}
+ ]
+
+ # Generate response
+ response = self.client.chat.completions.create(
+ model=self.model,
+ messages=messages,
+ max_tokens=4096,
+ temperature=0.7
+ )
+
+ response_text = response.choices[0].message.content
+
+ # Try to extract JSON modifications from response
+ modifications = self._extract_modifications(response_text)
+
+ return {
+ 'response': response_text,
+ 'modifications': modifications if modification_mode else None
+ }
+
+ except Exception as e:
+ return {
+ 'response': f"Error communicating with OpenAI: {str(e)}",
+ 'modifications': None
+ }
+
+ def _extract_modifications(self, response_text: str) -> Optional[List[Dict[str, Any]]]:
+ """Extract JSON modification suggestions from AI response."""
+ try:
+ # Look for JSON code blocks
+ import re
+ json_pattern = r'```json\s*(\{.*?\})\s*```'
+ matches = re.findall(json_pattern, response_text, re.DOTALL)
+
+ if matches:
+ modifications = []
+ for match in matches:
+ try:
+ mod = json.loads(match)
+ if 'action' in mod:
+ modifications.append(mod)
+ except json.JSONDecodeError:
+ continue
+
+ return modifications if modifications else None
+
+ return None
+
+ except Exception:
+ return None
+
+ def generate_suggestions(
+ self,
+ workflow_state: Dict[str, Any]
+ ) -> List[str]:
+ """
+ Generate architecture improvement suggestions based on current workflow.
+
+ Args:
+ workflow_state: Current workflow state (nodes and edges)
+
+ Returns:
+ List of suggestion strings
+ """
+ try:
+ workflow_context = self._format_workflow_context(workflow_state)
+
+ prompt = f"""Analyze this neural network architecture and provide 3-5 specific improvement suggestions.
+
+{workflow_context}
+
+Provide suggestions as a numbered list. Focus on:
+1. Architecture improvements (missing layers, better configurations)
+2. Common best practices
+3. Potential issues or bottlenecks
+4. Training optimization opportunities
+
+Format your response as a simple numbered list."""
+
+ response = self.client.chat.completions.create(
+ model=self.model,
+ messages=[
+ {'role': 'system', 'content': 'You are a helpful AI assistant for neural network architecture design.'},
+ {'role': 'user', 'content': prompt}
+ ],
+ max_tokens=1024,
+ temperature=0.7
+ )
+
+ response_text = response.choices[0].message.content
+
+ # Parse suggestions from numbered list
+ import re
+ suggestions = re.findall(r'\d+\.\s*(.+?)(?=\n\d+\.|\n*$)', response_text, re.DOTALL)
+ suggestions = [s.strip() for s in suggestions if s.strip()]
+
+ return suggestions[:5] # Return max 5 suggestions
+
+ except Exception as e:
+ return [f"Error generating suggestions: {str(e)}"]
diff --git a/project/block_manager/views/chat_views.py b/project/block_manager/views/chat_views.py
index af84935..7701283 100644
--- a/project/block_manager/views/chat_views.py
+++ b/project/block_manager/views/chat_views.py
@@ -47,9 +47,11 @@ def chat_message(request):
import json as json_lib
from django.conf import settings
- # Extract API keys from headers (only used in PROD mode)
+ # Extract API keys and active provider from headers (only used in PROD mode)
gemini_api_key = request.headers.get('X-Gemini-Api-Key')
anthropic_api_key = request.headers.get('X-Anthropic-Api-Key')
+ openai_api_key = request.headers.get('X-OpenAI-Api-Key')
+ active_provider = request.headers.get('X-Active-Provider') # 'gemini', 'claude', or 'openai'
# Check if request has file upload (FormData)
uploaded_file = request.FILES.get('file', None)
@@ -88,7 +90,9 @@ def chat_message(request):
# Initialize AI service with appropriate API keys based on mode
ai_service = AIServiceFactory.create_service(
gemini_api_key=gemini_api_key,
- anthropic_api_key=anthropic_api_key
+ anthropic_api_key=anthropic_api_key,
+ openai_api_key=openai_api_key,
+ active_provider=active_provider
)
provider_name = AIServiceFactory.get_provider_name()
@@ -195,9 +199,11 @@ def get_suggestions(request):
"suggestions": [str]
}
"""
- # Extract API keys from headers (only used in PROD mode)
+ # Extract API keys and active provider from headers (only used in PROD mode)
gemini_api_key = request.headers.get('X-Gemini-Api-Key')
anthropic_api_key = request.headers.get('X-Anthropic-Api-Key')
+ openai_api_key = request.headers.get('X-OpenAI-Api-Key')
+ active_provider = request.headers.get('X-Active-Provider') # 'gemini', 'claude', or 'openai'
nodes = request.data.get('nodes', [])
edges = request.data.get('edges', [])
@@ -211,7 +217,9 @@ def get_suggestions(request):
# Initialize AI service with appropriate API keys based on mode
ai_service = AIServiceFactory.create_service(
gemini_api_key=gemini_api_key,
- anthropic_api_key=anthropic_api_key
+ anthropic_api_key=anthropic_api_key,
+ openai_api_key=openai_api_key,
+ active_provider=active_provider
)
provider_name = AIServiceFactory.get_provider_name()
diff --git a/project/frontend/src/components/ApiKeyModal.tsx b/project/frontend/src/components/ApiKeyModal.tsx
index 21d650d..c337b42 100644
--- a/project/frontend/src/components/ApiKeyModal.tsx
+++ b/project/frontend/src/components/ApiKeyModal.tsx
@@ -11,8 +11,11 @@ import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Alert, AlertDescription } from '@/components/ui/alert'
-import { Info, Eye, EyeSlash } from '@phosphor-icons/react'
+import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
+import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
+import { Info, Eye, EyeSlash, Sparkle, Crown } from '@phosphor-icons/react'
import { useApiKeys } from '@/contexts/ApiKeyContext'
+import { useAuth } from '@/contexts/AuthContext'
interface ApiKeyModalProps {
open: boolean
@@ -20,41 +23,75 @@ interface ApiKeyModalProps {
required?: boolean
}
+type ProviderType = 'gemini' | 'claude' | 'openai'
+
export default function ApiKeyModal({ open, onOpenChange, required = false }: ApiKeyModalProps) {
const {
geminiApiKey,
anthropicApiKey,
- provider,
+ openaiApiKey,
+ activeProvider,
setGeminiApiKey,
setAnthropicApiKey,
+ setOpenAIApiKey,
+ setActiveProvider,
+ clearKeys,
hasRequiredKey
} = useApiKeys()
+ const { user } = useAuth()
+ const isPro = user?.tier === 'pro'
+
+ const [tier, setTier] = useState<'free' | 'pro'>('free')
+ const [selectedProvider, setSelectedProvider] = useState('gemini')
const [inputKey, setInputKey] = useState('')
const [showKey, setShowKey] = useState(false)
- // Load existing key when modal opens
+ // Load existing key when modal opens or provider changes
useEffect(() => {
if (open) {
- if (provider === 'Gemini' && geminiApiKey) {
+ // Set tier based on user or default to free
+ setTier(isPro ? 'pro' : 'free')
+
+ // Load the active provider's key
+ if (selectedProvider === 'gemini' && geminiApiKey) {
setInputKey(geminiApiKey)
- } else if (provider === 'Claude' && anthropicApiKey) {
+ } else if (selectedProvider === 'claude' && anthropicApiKey) {
setInputKey(anthropicApiKey)
+ } else if (selectedProvider === 'openai' && openaiApiKey) {
+ setInputKey(openaiApiKey)
+ } else {
+ setInputKey('')
}
}
- }, [open, provider, geminiApiKey, anthropicApiKey])
+ }, [open, selectedProvider, geminiApiKey, anthropicApiKey, openaiApiKey, isPro])
+
+ // Set selected provider based on active provider
+ useEffect(() => {
+ if (open) {
+ setSelectedProvider(activeProvider)
+ }
+ }, [open, activeProvider])
const handleSave = () => {
if (!inputKey.trim()) {
return
}
- if (provider === 'Gemini') {
- setGeminiApiKey(inputKey.trim())
- } else if (provider === 'Claude') {
- setAnthropicApiKey(inputKey.trim())
+ const trimmedKey = inputKey.trim()
+
+ // Save the API key for the selected provider
+ if (selectedProvider === 'gemini') {
+ setGeminiApiKey(trimmedKey)
+ } else if (selectedProvider === 'claude') {
+ setAnthropicApiKey(trimmedKey)
+ } else if (selectedProvider === 'openai') {
+ setOpenAIApiKey(trimmedKey)
}
+ // Set active provider
+ setActiveProvider(selectedProvider)
+
onOpenChange(false)
}
@@ -64,32 +101,40 @@ export default function ApiKeyModal({ open, onOpenChange, required = false }: Ap
}
}
- const getProviderInfo = () => {
- if (provider === 'Gemini') {
- return {
- name: 'Gemini',
- url: 'https://aistudio.google.com/app/apikey',
- placeholder: 'AIza...'
- }
- } else if (provider === 'Claude') {
- return {
- name: 'Claude',
- url: 'https://console.anthropic.com/',
- placeholder: 'sk-ant-...'
- }
- }
- return {
- name: 'AI Provider',
- url: '#',
- placeholder: 'Enter API key'
+ const getProviderInfo = (provider: ProviderType) => {
+ switch (provider) {
+ case 'gemini':
+ return {
+ name: 'Gemini',
+ displayName: 'Google Gemini',
+ url: 'https://aistudio.google.com/app/apikey',
+ placeholder: 'AIza...',
+ description: 'Fast, free tier available'
+ }
+ case 'openai':
+ return {
+ name: 'OpenAI',
+ displayName: 'OpenAI (GPT-4, GPT-3.5)',
+ url: 'https://platform.openai.com/api-keys',
+ placeholder: 'sk-proj-...',
+ description: 'Industry standard, most popular'
+ }
+ case 'claude':
+ return {
+ name: 'Claude',
+ displayName: 'Anthropic Claude',
+ url: 'https://console.anthropic.com/',
+ placeholder: 'sk-ant-...',
+ description: 'Advanced reasoning, latest models'
+ }
}
}
- const providerInfo = getProviderInfo()
+ const providerInfo = getProviderInfo(selectedProvider)
return (
- {/* API Key Button - Show when API keys are required */}
- {requiresApiKey && (
+ {/* Action Buttons */}
+
+ {/* API Key Button - Show when API keys are required */}
+ {requiresApiKey && (
+ setShowApiKeyModal(true)}
+ >
+
+ Edit API Key
+
+ )}
+
+ {/* Clear Chat Button */}
setShowApiKeyModal(true)}
+ className={requiresApiKey ? "flex-1" : "w-full"}
+ onClick={handleClearChat}
+ disabled={messages.length <= 1}
>
-
- Edit API Key
+
+ Clear Chat
- )}
+
{/* Messages Area */}
diff --git a/project/frontend/src/components/UniversalApiKeyModal.tsx b/project/frontend/src/components/UniversalApiKeyModal.tsx
index 01935ba..726ecc7 100644
--- a/project/frontend/src/components/UniversalApiKeyModal.tsx
+++ b/project/frontend/src/components/UniversalApiKeyModal.tsx
@@ -24,25 +24,20 @@ interface UniversalApiKeyModalProps {
}
type ModelType =
- // FREE OpenRouter models ($0/$0 with :free suffix - VERIFIED)
- | 'llama-4-maverick'
- | 'llama-4-scout'
+ // FREE OpenRouter models (VERIFIED WORKING - 404 errors removed)
| 'llama-3.3-70b'
- | 'gemini-2.0-flash-exp'
- | 'gemini-2.5-pro-exp'
- | 'mistral-small-3.1'
- | 'devstral-2'
+ | 'llama-3.1-70b'
+ | 'llama-3.1-8b'
+ | 'gemini-2.0-flash'
+ | 'gemini-3-flash'
+ | 'gemini-2.5-flash'
+ | 'mistral-nemo'
| 'deepseek-chat-v3'
- | 'deepseek-r1-zero'
- | 'nemotron-nano-8b'
- | 'mimo-v2-flash'
- | 'kat-coder-pro'
- | 'optimus-alpha'
- | 'quasar-alpha'
+ | 'deepseek-chat-v3.1'
+ | 'deepseek-v3.2'
+ | 'nemotron-nano-30b'
// Gemini models (Paid on OpenRouter, Free on Google AI)
- | 'gemini-3-flash'
| 'gemini-3-pro'
- | 'gemini-2.5-flash'
| 'gemini-2.5-pro'
// OpenAI models (Paid)
| 'gpt-5.2'
@@ -56,7 +51,6 @@ type ModelType =
| 'claude-3.5-haiku'
// Affordable PAID OpenRouter models
| 'llama-3.1-405b'
- | 'llama-3.1-70b'
| 'deepseek-v3'
| 'deepseek-coder-v2'
| 'qwen-2.5-72b'
@@ -83,7 +77,7 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
isFreeTier: boolean
message: string
} | null>(null)
- const [selectedModel, setSelectedModel] = useState((contextSelectedModel as ModelType) || 'devstral-2')
+ const [selectedModel, setSelectedModel] = useState((contextSelectedModel as ModelType) || 'llama-3.3-70b')
// Load existing key when modal opens
useEffect(() => {
@@ -205,27 +199,22 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
// Get all models organized by provider and pricing
const allModels = {
- // Truly FREE models ($0/$0 on OpenRouter with :free suffix - VERIFIED)
+ // Truly FREE models (VERIFIED WORKING - 404 errors removed)
free: [
- { id: 'llama-4-maverick', label: 'Llama 4 Maverick', desc: '400B flagship - FREE', free: true },
- { id: 'llama-4-scout', label: 'Llama 4 Scout', desc: '109B optimized - FREE', free: true },
{ id: 'llama-3.3-70b', label: 'Llama 3.3 70B', desc: '70B capable - FREE', free: true },
- { id: 'gemini-2.0-flash-exp', label: 'Gemini 2.0 Flash Exp', desc: 'Gemini free tier - FREE', free: true },
- { id: 'gemini-2.5-pro-exp', label: 'Gemini 2.5 Pro Exp', desc: 'Gemini Pro experimental - FREE', free: true },
- { id: 'mistral-small-3.1', label: 'Mistral Small 3.1', desc: '24B multimodal - FREE', free: true },
- { id: 'devstral-2', label: 'Devstral 2', desc: '123B coding specialist - FREE', free: true },
+ { id: 'llama-3.1-70b', label: 'Llama 3.1 70B', desc: '70B instruct - FREE', free: true },
+ { id: 'llama-3.1-8b', label: 'Llama 3.1 8B', desc: '8B efficient - FREE', free: true },
+ { id: 'gemini-2.0-flash', label: 'Gemini 2.0 Flash', desc: 'Fast Gemini - FREE', free: true },
+ { id: 'gemini-3-flash', label: 'Gemini 3 Flash', desc: 'Latest Gemini - FREE', free: true },
+ { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', desc: 'Gemini 2.5 - FREE', free: true },
+ { id: 'mistral-nemo', label: 'Mistral Nemo', desc: 'Mistral model - FREE', free: true },
{ id: 'deepseek-chat-v3', label: 'DeepSeek Chat V3', desc: 'Chat optimized - FREE', free: true },
- { id: 'deepseek-r1-zero', label: 'DeepSeek R1 Zero', desc: 'Reasoning - FREE', free: true },
- { id: 'nemotron-nano-8b', label: 'Nemotron Nano 8B', desc: 'NVIDIA 8B - FREE', free: true },
- { id: 'mimo-v2-flash', label: 'MiMo V2 Flash', desc: '309B MoE, 256K context - FREE', free: true },
- { id: 'kat-coder-pro', label: 'KAT Coder Pro', desc: 'Coding specialist - FREE', free: true },
- { id: 'optimus-alpha', label: 'Optimus Alpha', desc: 'OpenRouter general - FREE', free: true },
- { id: 'quasar-alpha', label: 'Quasar Alpha', desc: 'OpenRouter reasoning - FREE', free: true },
+ { id: 'deepseek-chat-v3.1', label: 'DeepSeek Chat V3.1', desc: 'Latest chat - FREE', free: true },
+ { id: 'deepseek-v3.2', label: 'DeepSeek V3.2', desc: 'DeepSeek flagship - FREE', free: true },
+ { id: 'nemotron-nano-30b', label: 'Nemotron Nano 30B', desc: 'NVIDIA 30B - FREE', free: true },
],
// Gemini models (PAID on OpenRouter, FREE on direct Google AI)
gemini: [
- { id: 'gemini-2.5-flash', label: 'Gemini 2.5 Flash', desc: 'Most affordable ($0.30/M)', free: false },
- { id: 'gemini-3-flash', label: 'Gemini 3 Flash', desc: 'Newest - frontier intelligence ($0.50/M)', free: false },
{ id: 'gemini-2.5-pro', label: 'Gemini 2.5 Pro', desc: 'Advanced thinking ($1.25/M)', free: false },
{ id: 'gemini-3-pro', label: 'Gemini 3 Pro', desc: 'Best multimodal ($2/M)', free: false },
],
@@ -247,7 +236,6 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
affordable: [
{ id: 'deepseek-v3', label: 'DeepSeek V3', desc: 'Latest flagship ($0.27/M)', free: false },
{ id: 'deepseek-coder-v2', label: 'DeepSeek Coder V2', desc: 'Coding optimized ($0.27/M)', free: false },
- { id: 'llama-3.1-70b', label: 'Llama 3.1 70B', desc: '70B paid version ($0.90/M)', free: false },
{ id: 'mistral-large-2', label: 'Mistral Large 2', desc: 'Mistral flagship ($2/M)', free: false },
{ id: 'llama-3.1-405b', label: 'Llama 3.1 405B', desc: 'Powerful 405B ($2.70/M)', free: false },
{ id: 'qwen-2.5-72b', label: 'Qwen 2.5 72B', desc: 'Chinese model - affordable', free: false },
@@ -259,12 +247,12 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
if (!validationResult || !validationResult.valid) {
// Show all models when no key or invalid key - FREE models first, then PAID
return [
- // TRULY FREE MODELS (14 total - $0/$0 VERIFIED)
- ...allModels.free, // Free (14 models)
- // AFFORDABLE PAID MODELS (6 total)
- ...allModels.affordable, // Affordable (6 models)
- // PAID GEMINI MODELS (4 total - free on Google AI)
- ...allModels.gemini, // Paid on OpenRouter (4 models)
+ // TRULY FREE MODELS (11 total - VERIFIED WORKING, 404 errors removed)
+ ...allModels.free, // Free (11 models)
+ // AFFORDABLE PAID MODELS (5 total)
+ ...allModels.affordable, // Affordable (5 models)
+ // PAID GEMINI MODELS (2 total - free on Google AI)
+ ...allModels.gemini, // Paid on OpenRouter (2 models)
// FLAGSHIP PAID MODELS (8 total)
...allModels.openai, // Paid (3 models)
...allModels.claude, // Paid (5 models)
@@ -276,12 +264,12 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
if (provider === 'openrouter') {
// OpenRouter has access to ALL models - FREE models first, then PAID
return [
- // TRULY FREE MODELS (14 total - $0/$0 VERIFIED)
- ...allModels.free, // Free (14 models)
- // AFFORDABLE PAID MODELS (6 total)
- ...allModels.affordable, // Affordable (6 models)
- // PAID GEMINI MODELS (4 total)
- ...allModels.gemini, // Paid on OpenRouter (4 models)
+ // TRULY FREE MODELS (11 total - VERIFIED WORKING, 404 errors removed)
+ ...allModels.free, // Free (11 models)
+ // AFFORDABLE PAID MODELS (5 total)
+ ...allModels.affordable, // Affordable (5 models)
+ // PAID GEMINI MODELS (2 total)
+ ...allModels.gemini, // Paid on OpenRouter (2 models)
// FLAGSHIP PAID MODELS (8 total)
...allModels.openai, // Paid (3 models)
...allModels.claude, // Paid (5 models)
From 0b73c8bd33c20b6b2acfd2da6429236f6bcdcc9e Mon Sep 17 00:00:00 2001
From: Gunbir Singh
Date: Wed, 24 Dec 2025 18:48:40 -0600
Subject: [PATCH 9/9] complete functionality
---
.../services/api_key_detector.py | 12 +++++------
.../block_manager/services/model_config.py | 14 ++++++-------
.../src/components/UniversalApiKeyModal.tsx | 20 +++++++++----------
3 files changed, 21 insertions(+), 25 deletions(-)
diff --git a/project/block_manager/services/api_key_detector.py b/project/block_manager/services/api_key_detector.py
index 2e829ff..42441e3 100644
--- a/project/block_manager/services/api_key_detector.py
+++ b/project/block_manager/services/api_key_detector.py
@@ -32,11 +32,11 @@ class APIKeyDetector:
name='openrouter',
display_name='OpenRouter',
key_prefix='sk-or-v1-',
- models_count=28,
+ models_count=25,
is_free_tier=True, # Has 11 truly free models (VERIFIED WORKING - 404 errors removed)
models=[
- # Flagship models via OpenRouter (10 models - all PAID)
- 'gemini-3-flash', 'gemini-3-pro', 'gemini-2.5-flash', 'gemini-2.5-pro',
+ # Flagship models via OpenRouter (8 models - all PAID)
+ 'gemini-3-pro', 'gemini-2.5-pro',
'gpt-5.2', 'gpt-4o', 'gpt-4o-mini',
'claude-opus-4.5', 'claude-sonnet-4.5', 'claude-haiku-4.5',
# Truly FREE OpenRouter models (11 models - VERIFIED WORKING)
@@ -45,11 +45,11 @@ class APIKeyDetector:
'mistral-nemo',
'deepseek-chat-v3', 'deepseek-chat-v3.1', 'deepseek-v3.2',
'nemotron-nano-30b',
- # Affordable PAID models (7 models)
+ # Affordable PAID models (6 models)
'llama-3.1-405b',
- 'deepseek-v3', 'deepseek-coder-v2',
+ 'deepseek-v3',
'claude-3.5-sonnet', 'claude-3.5-haiku',
- 'qwen-2.5-72b', 'mistral-large-2'
+ 'qwen-2.5-72b', 'mistral-large-3'
]
),
'google': ProviderInfo(
diff --git a/project/block_manager/services/model_config.py b/project/block_manager/services/model_config.py
index 1ac3b2c..8cecf91 100644
--- a/project/block_manager/services/model_config.py
+++ b/project/block_manager/services/model_config.py
@@ -6,10 +6,9 @@
# Gemini model mapping for OpenRouter (frontend name -> OpenRouter identifier)
# Latest models as of December 2025 - PAID on OpenRouter (Free on direct Google AI API)
# Reference: https://openrouter.ai/google
+# NOTE: gemini-3-flash and gemini-2.5-flash are in FREE models list (verified working)
GEMINI_MODELS = {
- 'gemini-3-flash': 'google/gemini-3-flash-preview', # Newest - Dec 17, 2025 ($0.50/M input)
- 'gemini-3-pro': 'google/gemini-3-pro-preview', # Nov 18, 2025 - multimodal ($2/M input)
- 'gemini-2.5-flash': 'google/gemini-2.5-flash', # Most affordable ($0.30/M input)
+ 'gemini-3-pro': 'google/gemini-3-pro-preview-20251117', # Nov 18, 2025 - multimodal ($2/M input)
'gemini-2.5-pro': 'google/gemini-2.5-pro', # Advanced thinking ($1.25/M input)
}
@@ -80,19 +79,18 @@
# Reference: https://openrouter.ai/pricing
OPENROUTER_PAID_MODELS = {
# Affordable Llama models (paid versions)
- 'llama-3.1-405b': 'meta-llama/llama-3.1-405b-instruct', # $2.70/M - powerful 405B
+ 'llama-3.1-405b': 'meta-llama/llama-3.1-405b-instruct', # $3.50/M - powerful 405B
# Affordable DeepSeek models (paid versions - not on free list)
- 'deepseek-v3': 'deepseek/deepseek-v3', # $0.27/M - latest flagship
- 'deepseek-coder-v2': 'deepseek/deepseek-coder-v2', # $0.27/M - coding optimized
+ 'deepseek-v3': 'deepseek/deepseek-chat', # $0.30/M - latest flagship (DeepSeek V3)
# Older Claude (still very good, cheaper than 4.5)
'claude-3.5-sonnet': 'anthropic/claude-3.5-sonnet', # $3/M - very capable
'claude-3.5-haiku': 'anthropic/claude-3.5-haiku', # $0.80/M - cheapest Claude
# Alternative affordable providers
- 'qwen-2.5-72b': 'qwen/qwen-2.5-72b-instruct', # Chinese model - affordable
- 'mistral-large-2': 'mistralai/mistral-large-2', # $2/M - Mistral flagship
+ 'qwen-2.5-72b': 'qwen/qwen-2.5-72b-instruct', # $0.12/M - Chinese model, affordable
+ 'mistral-large-3': 'mistralai/mistral-large-2512', # Mistral Large 3 2512 - latest flagship
}
# Combined model mapping (for OpenRouter)
diff --git a/project/frontend/src/components/UniversalApiKeyModal.tsx b/project/frontend/src/components/UniversalApiKeyModal.tsx
index 726ecc7..a65f911 100644
--- a/project/frontend/src/components/UniversalApiKeyModal.tsx
+++ b/project/frontend/src/components/UniversalApiKeyModal.tsx
@@ -52,9 +52,8 @@ type ModelType =
// Affordable PAID OpenRouter models
| 'llama-3.1-405b'
| 'deepseek-v3'
- | 'deepseek-coder-v2'
| 'qwen-2.5-72b'
- | 'mistral-large-2'
+ | 'mistral-large-3'
export default function UniversalApiKeyModal({ open, onOpenChange, required = false }: UniversalApiKeyModalProps) {
const {
@@ -234,11 +233,10 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
],
// Affordable OpenRouter models (PAID but cheap)
affordable: [
- { id: 'deepseek-v3', label: 'DeepSeek V3', desc: 'Latest flagship ($0.27/M)', free: false },
- { id: 'deepseek-coder-v2', label: 'DeepSeek Coder V2', desc: 'Coding optimized ($0.27/M)', free: false },
- { id: 'mistral-large-2', label: 'Mistral Large 2', desc: 'Mistral flagship ($2/M)', free: false },
- { id: 'llama-3.1-405b', label: 'Llama 3.1 405B', desc: 'Powerful 405B ($2.70/M)', free: false },
- { id: 'qwen-2.5-72b', label: 'Qwen 2.5 72B', desc: 'Chinese model - affordable', free: false },
+ { id: 'deepseek-v3', label: 'DeepSeek V3', desc: 'Latest flagship ($0.30/M)', free: false },
+ { id: 'mistral-large-3', label: 'Mistral Large 3', desc: 'Mistral flagship 2512', free: false },
+ { id: 'llama-3.1-405b', label: 'Llama 3.1 405B', desc: 'Powerful 405B ($3.50/M)', free: false },
+ { id: 'qwen-2.5-72b', label: 'Qwen 2.5 72B', desc: 'Chinese model ($0.12/M)', free: false },
]
}
@@ -249,8 +247,8 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
return [
// TRULY FREE MODELS (11 total - VERIFIED WORKING, 404 errors removed)
...allModels.free, // Free (11 models)
- // AFFORDABLE PAID MODELS (5 total)
- ...allModels.affordable, // Affordable (5 models)
+ // AFFORDABLE PAID MODELS (4 total)
+ ...allModels.affordable, // Affordable (4 models)
// PAID GEMINI MODELS (2 total - free on Google AI)
...allModels.gemini, // Paid on OpenRouter (2 models)
// FLAGSHIP PAID MODELS (8 total)
@@ -266,8 +264,8 @@ export default function UniversalApiKeyModal({ open, onOpenChange, required = fa
return [
// TRULY FREE MODELS (11 total - VERIFIED WORKING, 404 errors removed)
...allModels.free, // Free (11 models)
- // AFFORDABLE PAID MODELS (5 total)
- ...allModels.affordable, // Affordable (5 models)
+ // AFFORDABLE PAID MODELS (4 total)
+ ...allModels.affordable, // Affordable (4 models)
// PAID GEMINI MODELS (2 total)
...allModels.gemini, // Paid on OpenRouter (2 models)
// FLAGSHIP PAID MODELS (8 total)