diff --git a/backend/services/billing.py b/backend/services/billing.py index 1f46a571..1b37a553 100644 --- a/backend/services/billing.py +++ b/backend/services/billing.py @@ -13,8 +13,8 @@ from utils.config import config, EnvMode from services.supabase import DBConnection from utils.auth_utils import get_current_user_id_from_jwt from pydantic import BaseModel -from utils.constants import MODEL_ACCESS_TIERS, MODEL_NAME_ALIASES -from litellm import cost_per_token +from utils.constants import MODEL_ACCESS_TIERS, MODEL_NAME_ALIASES, HARDCODED_MODEL_PRICES +from litellm.cost_calculator import cost_per_token import time # Initialize Stripe @@ -26,46 +26,6 @@ TOKEN_PRICE_MULTIPLIER = 1.5 # Initialize router router = APIRouter(prefix="/billing", tags=["billing"]) -# Hardcoded pricing for specific models (prices per million tokens) -HARDCODED_MODEL_PRICES = { - "openrouter/deepseek/deepseek-chat": { - "input_cost_per_million_tokens": 0.38, - "output_cost_per_million_tokens": 0.89 - }, - "deepseek/deepseek-chat": { - "input_cost_per_million_tokens": 0.38, - "output_cost_per_million_tokens": 0.89 - }, - "qwen/qwen3-235b-a22b": { - "input_cost_per_million_tokens": 0.13, - "output_cost_per_million_tokens": 0.60 - }, - "openrouter/qwen/qwen3-235b-a22b": { - "input_cost_per_million_tokens": 0.13, - "output_cost_per_million_tokens": 0.60 - }, - "google/gemini-2.5-flash-preview-05-20": { - "input_cost_per_million_tokens": 0.15, - "output_cost_per_million_tokens": 0.60 - }, - "openrouter/google/gemini-2.5-flash-preview-05-20": { - "input_cost_per_million_tokens": 0.15, - "output_cost_per_million_tokens": 0.60 - }, - "anthropic/claude-sonnet-4": { - "input_cost_per_million_tokens": 3.00, - "output_cost_per_million_tokens": 15.00, - }, - "google/gemini-2.5-pro": { - "input_cost_per_million_tokens": 1.25, - "output_cost_per_million_tokens": 10.00, - }, - "openrouter/google/gemini-2.5-pro": { - "input_cost_per_million_tokens": 1.25, - "output_cost_per_million_tokens": 10.00, - }, -} - def get_model_pricing(model: str) -> tuple[float, float] | None: """ Get pricing for a model. Returns (input_cost_per_million, output_cost_per_million) or None. diff --git a/backend/services/llm.py b/backend/services/llm.py index 99b5e6f2..fe52e87e 100644 --- a/backend/services/llm.py +++ b/backend/services/llm.py @@ -2,7 +2,7 @@ LLM API interface for making calls to various language models. This module provides a unified interface for making API calls to different LLM providers -(OpenAI, Anthropic, Groq, etc.) using LiteLLM. It includes support for: +(OpenAI, Anthropic, Groq, xAI, etc.) using LiteLLM. It includes support for: - Streaming responses - Tool calls and function calling - Retry logic with exponential backoff @@ -16,6 +16,7 @@ import json import asyncio from openai import OpenAIError import litellm +from litellm.files.main import ModelResponse from utils.logger import logger from utils.config import config @@ -37,7 +38,7 @@ class LLMRetryError(LLMError): def setup_api_keys() -> None: """Set up API keys from environment variables.""" - providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER'] + providers = ['OPENAI', 'ANTHROPIC', 'GROQ', 'OPENROUTER', 'XAI'] for provider in providers: key = getattr(config, f'{provider}_API_KEY') if key: @@ -64,6 +65,36 @@ def setup_api_keys() -> None: else: logger.warning(f"Missing AWS credentials for Bedrock integration - access_key: {bool(aws_access_key)}, secret_key: {bool(aws_secret_key)}, region: {aws_region}") +def get_openrouter_fallback(model_name: str) -> Optional[str]: + """Get OpenRouter fallback model for a given model name.""" + # Skip if already using OpenRouter + if model_name.startswith("openrouter/"): + return None + + # Map models to their OpenRouter equivalents + fallback_mapping = { + "anthropic/claude-3-7-sonnet-latest": "openrouter/anthropic/claude-3.7-sonnet", + "anthropic/claude-sonnet-4-20250514": "openrouter/anthropic/claude-sonnet-4", + "xai/grok-4": "openrouter/x-ai/grok-4", + } + + # Check for exact match first + if model_name in fallback_mapping: + return fallback_mapping[model_name] + + # Check for partial matches (e.g., bedrock models) + for key, value in fallback_mapping.items(): + if key in model_name: + return value + + # Default fallbacks by provider + if "claude" in model_name.lower() or "anthropic" in model_name.lower(): + return "openrouter/anthropic/claude-sonnet-4" + elif "xai" in model_name.lower() or "grok" in model_name.lower(): + return "openrouter/x-ai/grok-4" + + return None + async def handle_error(error: Exception, attempt: int, max_attempts: int) -> None: """Handle API errors with appropriate delays and logging.""" delay = RATE_LIMIT_DELAY if isinstance(error, litellm.exceptions.RateLimitError) else RETRY_DELAY @@ -196,6 +227,7 @@ def prepare_params( # Add reasoning_effort for Anthropic models if enabled use_thinking = enable_thinking if enable_thinking is not None else False is_anthropic = "anthropic" in effective_model_name.lower() or "claude" in effective_model_name.lower() + is_xai = "xai" in effective_model_name.lower() or model_name.startswith("xai/") if is_anthropic and use_thinking: effort_level = reasoning_effort if reasoning_effort else 'low' @@ -203,6 +235,17 @@ def prepare_params( params["temperature"] = 1.0 # Required by Anthropic when reasoning_effort is used logger.info(f"Anthropic thinking enabled with reasoning_effort='{effort_level}'") + # Add reasoning_effort for xAI models if enabled + if is_xai and use_thinking: + effort_level = reasoning_effort if reasoning_effort else 'low' + params["reasoning_effort"] = effort_level + logger.info(f"xAI thinking enabled with reasoning_effort='{effort_level}'") + + # Add xAI-specific parameters + if model_name.startswith("xai/"): + logger.debug(f"Preparing xAI parameters for model: {model_name}") + # xAI models support standard parameters, no special handling needed beyond reasoning_effort + return params async def make_llm_api_call( @@ -220,7 +263,7 @@ async def make_llm_api_call( model_id: Optional[str] = None, enable_thinking: Optional[bool] = False, reasoning_effort: Optional[str] = 'low' -) -> Union[Dict[str, Any], AsyncGenerator]: +) -> Union[Dict[str, Any], AsyncGenerator, ModelResponse]: """ Make an API call to a language model using LiteLLM. @@ -277,6 +320,27 @@ async def make_llm_api_call( # logger.debug(f"Response: {response}") return response + except litellm.exceptions.InternalServerError as e: + # Check if it's an Anthropic overloaded error + if "Overloaded" in str(e) and "AnthropicException" in str(e): + fallback_model = get_openrouter_fallback(model_name) + if fallback_model and not params.get("model", "").startswith("openrouter/"): + logger.warning(f"Anthropic overloaded, falling back to OpenRouter: {fallback_model}") + params["model"] = fallback_model + # Remove any model_id as it's specific to Bedrock + params.pop("model_id", None) + # Continue with next attempt using fallback model + last_error = e + await handle_error(e, attempt, MAX_RETRIES) + else: + # No fallback available or already using OpenRouter + last_error = e + await handle_error(e, attempt, MAX_RETRIES) + else: + # Other internal server errors + last_error = e + await handle_error(e, attempt, MAX_RETRIES) + except (litellm.exceptions.RateLimitError, OpenAIError, json.JSONDecodeError) as e: last_error = e await handle_error(e, attempt, MAX_RETRIES) diff --git a/backend/utils/config.py b/backend/utils/config.py index 4128668e..5a63de6e 100644 --- a/backend/utils/config.py +++ b/backend/utils/config.py @@ -174,6 +174,7 @@ class Configuration: OPENAI_API_KEY: Optional[str] = None GROQ_API_KEY: Optional[str] = None OPENROUTER_API_KEY: Optional[str] = None + XAI_API_KEY: Optional[str] = None OPENROUTER_API_BASE: Optional[str] = "https://openrouter.ai/api/v1" OR_SITE_URL: Optional[str] = "https://kortix.ai" OR_APP_NAME: Optional[str] = "Kortix AI" diff --git a/backend/utils/constants.py b/backend/utils/constants.py index 984079ed..fd05e711 100644 --- a/backend/utils/constants.py +++ b/backend/utils/constants.py @@ -1,181 +1,165 @@ -MODEL_ACCESS_TIERS = { - "free": [ - "openrouter/deepseek/deepseek-chat", - "openrouter/qwen/qwen3-235b-a22b", - "openrouter/google/gemini-2.5-flash-preview-05-20", - "anthropic/claude-sonnet-4-20250514", - ], - "tier_2_20": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - "anthropic/claude-3-5-haiku-latest", - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - "openrouter/deepseek/deepseek-chat-v3-0324", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_6_50": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - "anthropic/claude-3-5-haiku-latest", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - "openrouter/deepseek/deepseek-chat-v3-0324", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_12_100": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", +# Master model configuration - single source of truth +MODELS = { + # Free tier models + + "anthropic/claude-sonnet-4-20250514": { + "aliases": ["claude-sonnet-4"], + "pricing": { + "input_cost_per_million_tokens": 3.00, + "output_cost_per_million_tokens": 15.00 + }, + "tier_availability": ["free", "paid"] + }, - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - "openrouter/deepseek/deepseek-chat-v3-0324", - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-3-5-haiku-latest", - "anthropic/claude-sonnet-4-20250514", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_25_200": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - "openrouter/deepseek/deepseek-chat-v3-0324", - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - "anthropic/claude-3-5-haiku-latest", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_50_400": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-sonnet-4-20250514", - "anthropic/claude-3-5-haiku-latest", - "openrouter/deepseek/deepseek-chat-v3-0324", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_125_800": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-3-5-haiku-latest", - "anthropic/claude-sonnet-4-20250514", - "openrouter/deepseek/deepseek-chat-v3-0324", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], - "tier_200_1000": [ - "openrouter/deepseek/deepseek-chat", - # "xai/grok-3-mini-fast-beta", - "openai/gpt-4o", - # "openai/gpt-4-turbo", - # "xai/grok-3-fast-latest", - "openrouter/google/gemini-2.5-flash-preview-05-20", # Added - "openrouter/google/gemini-2.5-pro", # Added Gemini 2.5 Pro - # "openai/gpt-4", - "anthropic/claude-3-7-sonnet-latest", - "anthropic/claude-3-5-sonnet-latest", - "anthropic/claude-3-5-haiku-latest", - "anthropic/claude-sonnet-4-20250514", - "openrouter/deepseek/deepseek-chat-v3-0324", - "openai/gpt-4.1", - "openai/gpt-4.1-mini", - # "openrouter/deepseek/deepseek-r1", - "openrouter/qwen/qwen3-235b-a22b", - ], + "openrouter/deepseek/deepseek-chat": { + "aliases": ["deepseek"], + "pricing": { + "input_cost_per_million_tokens": 0.38, + "output_cost_per_million_tokens": 0.89 + }, + "tier_availability": ["free", "paid"] + }, + "openrouter/qwen/qwen3-235b-a22b": { + "aliases": ["qwen3"], + "pricing": { + "input_cost_per_million_tokens": 0.13, + "output_cost_per_million_tokens": 0.60 + }, + "tier_availability": ["free", "paid"] + }, + "openrouter/google/gemini-2.5-flash-preview-05-20": { + "aliases": ["gemini-flash-2.5"], + "pricing": { + "input_cost_per_million_tokens": 0.15, + "output_cost_per_million_tokens": 0.60 + }, + "tier_availability": ["free", "paid"] + }, + + # Paid tier only models + "openrouter/deepseek/deepseek-chat-v3-0324": { + "aliases": ["deepseek/deepseek-chat-v3-0324"], + "pricing": { + "input_cost_per_million_tokens": 0.38, + "output_cost_per_million_tokens": 0.89 + }, + "tier_availability": ["paid"] + }, + "openrouter/google/gemini-2.5-pro": { + "aliases": ["google/gemini-2.5-pro"], + "pricing": { + "input_cost_per_million_tokens": 1.25, + "output_cost_per_million_tokens": 10.00 + }, + "tier_availability": ["paid"] + }, + "openai/gpt-4o": { + "aliases": ["gpt-4o"], + "pricing": { + "input_cost_per_million_tokens": 2.50, + "output_cost_per_million_tokens": 10.00 + }, + "tier_availability": ["paid"] + }, + "openai/gpt-4.1": { + "aliases": ["gpt-4.1"], + "pricing": { + "input_cost_per_million_tokens": 15.00, + "output_cost_per_million_tokens": 60.00 + }, + "tier_availability": ["paid"] + }, + "openai/gpt-4.1-mini": { + "aliases": ["gpt-4.1-mini"], + "pricing": { + "input_cost_per_million_tokens": 1.50, + "output_cost_per_million_tokens": 6.00 + }, + "tier_availability": ["paid"] + }, + "anthropic/claude-3-7-sonnet-latest": { + "aliases": ["sonnet-3.7"], + "pricing": { + "input_cost_per_million_tokens": 3.00, + "output_cost_per_million_tokens": 15.00 + }, + "tier_availability": ["paid"] + }, + "anthropic/claude-3-5-sonnet-latest": { + "aliases": ["sonnet-3.5"], + "pricing": { + "input_cost_per_million_tokens": 3.00, + "output_cost_per_million_tokens": 15.00 + }, + "tier_availability": ["paid"] + }, + + "openrouter/x-ai/grok-4": { + "aliases": ["grok-4"], + "pricing": { + "input_cost_per_million_tokens": 5.00, + "output_cost_per_million_tokens": 15.00 + }, + "tier_availability": ["paid"] + }, + } -MODEL_NAME_ALIASES = { - # Short names to full names - "sonnet-3.7": "anthropic/claude-3-7-sonnet-latest", - "sonnet-3.5": "anthropic/claude-3-5-sonnet-latest", - "haiku-3.5": "anthropic/claude-3-5-haiku-latest", - "claude-sonnet-4": "anthropic/claude-sonnet-4-20250514", - # "gpt-4.1": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py - "gpt-4o": "openai/gpt-4o", - "gpt-4.1": "openai/gpt-4.1", - "gpt-4.1-mini": "openai/gpt-4.1-mini", - # "gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py - # "gpt-4": "openai/gpt-4", # Commented out in constants.py - # "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py - # "grok-3": "xai/grok-3-fast-latest", # Commented out in constants.py - "deepseek": "openrouter/deepseek/deepseek-chat", - # "deepseek-r1": "openrouter/deepseek/deepseek-r1", - # "grok-3-mini": "xai/grok-3-mini-fast-beta", # Commented out in constants.py - "qwen3": "openrouter/qwen/qwen3-235b-a22b", # Commented out in constants.py - "gemini-flash-2.5": "openrouter/google/gemini-2.5-flash-preview-05-20", - "gemini-2.5-flash:thinking": "openrouter/google/gemini-2.5-flash-preview-05-20:thinking", - # "google/gemini-2.5-flash-preview":"openrouter/google/gemini-2.5-flash-preview", - # "google/gemini-2.5-flash-preview:thinking":"openrouter/google/gemini-2.5-flash-preview:thinking", - "google/gemini-2.5-pro": "openrouter/google/gemini-2.5-pro", - "deepseek/deepseek-chat-v3-0324": "openrouter/deepseek/deepseek-chat-v3-0324", - # Also include full names as keys to ensure they map to themselves - # "anthropic/claude-3-7-sonnet-latest": "anthropic/claude-3-7-sonnet-latest", - # "openai/gpt-4.1-2025-04-14": "openai/gpt-4.1-2025-04-14", # Commented out in constants.py - # "openai/gpt-4o": "openai/gpt-4o", - # "openai/gpt-4-turbo": "openai/gpt-4-turbo", # Commented out in constants.py - # "openai/gpt-4": "openai/gpt-4", # Commented out in constants.py - # "openrouter/google/gemini-2.5-flash-preview": "openrouter/google/gemini-2.5-flash-preview", # Commented out in constants.py - # "xai/grok-3-fast-latest": "xai/grok-3-fast-latest", # Commented out in constants.py - # "deepseek/deepseek-chat": "openrouter/deepseek/deepseek-chat", - # "deepseek/deepseek-r1": "openrouter/deepseek/deepseek-r1", - # "qwen/qwen3-235b-a22b": "openrouter/qwen/qwen3-235b-a22b", - # "xai/grok-3-mini-fast-beta": "xai/grok-3-mini-fast-beta", # Commented out in constants.py + +# Derived structures (auto-generated from MODELS) +def _generate_model_structures(): + """Generate all model structures from the master MODELS dictionary.""" + + # Generate tier lists + free_models = [] + paid_models = [] + + # Generate aliases + aliases = {} + + # Generate pricing + pricing = {} + + for model_name, config in MODELS.items(): + # Add to tier lists + if "free" in config["tier_availability"]: + free_models.append(model_name) + if "paid" in config["tier_availability"]: + paid_models.append(model_name) + + # Add aliases + for alias in config["aliases"]: + aliases[alias] = model_name + + # Add pricing + pricing[model_name] = config["pricing"] + + # Also add pricing for legacy model name variations + if model_name.startswith("openrouter/deepseek/"): + legacy_name = model_name.replace("openrouter/", "") + pricing[legacy_name] = config["pricing"] + elif model_name.startswith("openrouter/qwen/"): + legacy_name = model_name.replace("openrouter/", "") + pricing[legacy_name] = config["pricing"] + elif model_name.startswith("openrouter/google/"): + legacy_name = model_name.replace("openrouter/", "") + pricing[legacy_name] = config["pricing"] + elif model_name.startswith("anthropic/"): + # Add anthropic/claude-sonnet-4 alias for claude-sonnet-4-20250514 + if "claude-sonnet-4-20250514" in model_name: + pricing["anthropic/claude-sonnet-4"] = config["pricing"] + + return free_models, paid_models, aliases, pricing + +# Generate all structures +FREE_TIER_MODELS, PAID_TIER_MODELS, MODEL_NAME_ALIASES, HARDCODED_MODEL_PRICES = _generate_model_structures() + +MODEL_ACCESS_TIERS = { + "free": FREE_TIER_MODELS, + "tier_2_20": PAID_TIER_MODELS, + "tier_6_50": PAID_TIER_MODELS, + "tier_12_100": PAID_TIER_MODELS, + "tier_25_200": PAID_TIER_MODELS, + "tier_50_400": PAID_TIER_MODELS, + "tier_125_800": PAID_TIER_MODELS, + "tier_200_1000": PAID_TIER_MODELS, } diff --git a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx index 3a23611a..6429c760 100644 --- a/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx +++ b/frontend/src/app/(dashboard)/projects/[projectId]/thread/[threadId]/page.tsx @@ -639,7 +639,7 @@ export default function ThreadPage({
diff --git a/frontend/src/app/auth/github-popup/page.tsx b/frontend/src/app/auth/github-popup/page.tsx new file mode 100644 index 00000000..837a0228 --- /dev/null +++ b/frontend/src/app/auth/github-popup/page.tsx @@ -0,0 +1,207 @@ +'use client'; + +import { useEffect, useState } from 'react'; +import { createClient } from '@/lib/supabase/client'; +import { Loader2 } from 'lucide-react'; + +interface AuthMessage { + type: 'github-auth-success' | 'github-auth-error'; + message?: string; + returnUrl?: string; +} + +export default function GitHubOAuthPopup() { + const [status, setStatus] = useState<'loading' | 'processing' | 'error'>( + 'loading', + ); + const [errorMessage, setErrorMessage] = useState(''); + + useEffect(() => { + const supabase = createClient(); + + // Get return URL from sessionStorage (set by parent component) + const returnUrl = + sessionStorage.getItem('github-returnUrl') || '/dashboard'; + + const postMessage = (message: AuthMessage) => { + try { + if (window.opener && !window.opener.closed) { + window.opener.postMessage(message, window.location.origin); + } + } catch (err) { + console.error('Failed to post message to opener:', err); + } + }; + + const handleSuccess = () => { + setStatus('processing'); + postMessage({ + type: 'github-auth-success', + returnUrl, + }); + + // Close popup after short delay + setTimeout(() => { + window.close(); + }, 500); + }; + + const handleError = (message: string) => { + setStatus('error'); + setErrorMessage(message); + postMessage({ + type: 'github-auth-error', + message, + }); + + // Close popup after delay to show error + setTimeout(() => { + window.close(); + }, 2000); + }; + + const handleOAuth = async () => { + try { + const urlParams = new URLSearchParams(window.location.search); + const isCallback = urlParams.has('code'); + const hasError = urlParams.has('error'); + + // Handle OAuth errors + if (hasError) { + const error = urlParams.get('error'); + const errorDescription = urlParams.get('error_description'); + throw new Error(errorDescription || error || 'GitHub OAuth error'); + } + + if (isCallback) { + // This is the callback from GitHub + setStatus('processing'); + + try { + // Wait a moment for Supabase to process the session + await new Promise((resolve) => setTimeout(resolve, 1000)); + + const { + data: { session }, + error, + } = await supabase.auth.getSession(); + + if (error) { + throw error; + } + + if (session?.user) { + handleSuccess(); + return; + } + + // If no session yet, listen for auth state change + const { + data: { subscription }, + } = supabase.auth.onAuthStateChange(async (event, session) => { + if (event === 'SIGNED_IN' && session?.user) { + subscription.unsubscribe(); + handleSuccess(); + } else if (event === 'SIGNED_OUT') { + subscription.unsubscribe(); + handleError('Authentication failed - please try again'); + } + }); + + // Cleanup subscription after timeout + setTimeout(() => { + subscription.unsubscribe(); + handleError('Authentication timeout - please try again'); + }, 10000); // 10 second timeout + } catch (authError: any) { + console.error('Auth processing error:', authError); + handleError(authError.message || 'Authentication failed'); + } + } else { + // Start the OAuth flow + setStatus('loading'); + + const { error } = await supabase.auth.signInWithOAuth({ + provider: 'github', + options: { + redirectTo: `${window.location.origin}/auth/github-popup`, + queryParams: { + access_type: 'online', + prompt: 'select_account', + }, + }, + }); + + if (error) { + throw error; + } + } + } catch (err: any) { + console.error('OAuth error:', err); + handleError(err.message || 'Failed to authenticate with GitHub'); + } + }; + + // Cleanup sessionStorage when popup closes + const handleBeforeUnload = () => { + sessionStorage.removeItem('github-returnUrl'); + }; + + window.addEventListener('beforeunload', handleBeforeUnload); + + // Start OAuth process + handleOAuth(); + + return () => { + window.removeEventListener('beforeunload', handleBeforeUnload); + }; + }, []); + + const getStatusMessage = () => { + switch (status) { + case 'loading': + return 'Starting GitHub authentication...'; + case 'processing': + return 'Completing sign-in...'; + case 'error': + return errorMessage || 'Authentication failed'; + default: + return 'Processing...'; + } + }; + + const getStatusColor = () => { + switch (status) { + case 'error': + return 'text-red-500'; + case 'processing': + return 'text-green-500'; + default: + return 'text-muted-foreground'; + } + }; + + return ( +
+
+ {status !== 'error' && ( + + )} + +
+

GitHub Sign-In

+

{getStatusMessage()}

+
+ + {status === 'error' && ( + + )} +
+
+ ); +} \ No newline at end of file diff --git a/frontend/src/app/auth/page.tsx b/frontend/src/app/auth/page.tsx index b90c143b..c3599390 100644 --- a/frontend/src/app/auth/page.tsx +++ b/frontend/src/app/auth/page.tsx @@ -28,6 +28,7 @@ import { DialogDescription, DialogFooter, } from '@/components/ui/dialog'; +import GitHubSignIn from '@/components/GithubSignIn'; function LoginContent() { const router = useRouter(); @@ -387,11 +388,17 @@ function LoginContent() {
)} - {/* Google Sign In */} -
- + {/* OAuth Sign In */} +
+
+ +
+
+ +
+ {/* Divider */}
diff --git a/frontend/src/components/GithubSignIn.tsx b/frontend/src/components/GithubSignIn.tsx new file mode 100644 index 00000000..e2207b02 --- /dev/null +++ b/frontend/src/components/GithubSignIn.tsx @@ -0,0 +1,180 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { useTheme } from 'next-themes'; +import { toast } from 'sonner'; +import { Icons } from './home/icons'; + +interface GitHubSignInProps { + returnUrl?: string; +} + +interface AuthMessage { + type: 'github-auth-success' | 'github-auth-error'; + message?: string; + returnUrl?: string; +} + +export default function GitHubSignIn({ returnUrl }: GitHubSignInProps) { + const [isLoading, setIsLoading] = useState(false); + const { resolvedTheme } = useTheme(); + + // Cleanup function to handle auth state + const cleanupAuthState = useCallback(() => { + sessionStorage.removeItem('isGitHubAuthInProgress'); + setIsLoading(false); + }, []); + + // Handle success message + const handleSuccess = useCallback( + (data: AuthMessage) => { + cleanupAuthState(); + + // Add a small delay to ensure state is properly cleared + setTimeout(() => { + window.location.href = data.returnUrl || returnUrl || '/dashboard'; + }, 100); + }, + [cleanupAuthState, returnUrl], + ); + + // Handle error message + const handleError = useCallback( + (data: AuthMessage) => { + cleanupAuthState(); + toast.error(data.message || 'GitHub sign-in failed. Please try again.'); + }, + [cleanupAuthState], + ); + + // Message event handler + useEffect(() => { + const handleMessage = (event: MessageEvent) => { + // Security: Only accept messages from same origin + if (event.origin !== window.location.origin) { + console.warn( + 'Rejected message from unauthorized origin:', + event.origin, + ); + return; + } + + // Validate message structure + if (!event.data?.type || typeof event.data.type !== 'string') { + return; + } + + switch (event.data.type) { + case 'github-auth-success': + handleSuccess(event.data); + break; + case 'github-auth-error': + handleError(event.data); + break; + default: + // Ignore unknown message types + break; + } + }; + + window.addEventListener('message', handleMessage); + + return () => { + window.removeEventListener('message', handleMessage); + }; + }, [handleSuccess, handleError]); + + // Cleanup on component unmount + useEffect(() => { + return () => { + cleanupAuthState(); + }; + }, [cleanupAuthState]); + + const handleGitHubSignIn = async () => { + if (isLoading) return; + + let popupInterval: NodeJS.Timeout | null = null; + + try { + setIsLoading(true); + + // Store return URL for the popup + if (returnUrl) { + sessionStorage.setItem('github-returnUrl', returnUrl || '/dashboard'); + } + + // Open popup with proper dimensions and features + const popup = window.open( + `${window.location.origin}/auth/github-popup`, + 'GitHubOAuth', + 'width=500,height=600,scrollbars=yes,resizable=yes,status=yes,location=yes', + ); + + if (!popup) { + throw new Error( + 'Popup was blocked. Please enable popups and try again.', + ); + } + + // Set loading state and track popup + sessionStorage.setItem('isGitHubAuthInProgress', '1'); + + // Monitor popup closure + popupInterval = setInterval(() => { + if (popup.closed) { + if (popupInterval) { + clearInterval(popupInterval); + popupInterval = null; + } + + // Small delay to allow postMessage to complete + setTimeout(() => { + if (sessionStorage.getItem('isGitHubAuthInProgress')) { + cleanupAuthState(); + toast.error('GitHub sign-in was cancelled or not completed.'); + } + }, 500); + } + }, 1000); + } catch (error) { + console.error('GitHub sign-in error:', error); + if (popupInterval) { + clearInterval(popupInterval); + } + cleanupAuthState(); + toast.error( + error instanceof Error + ? error.message + : 'Failed to start GitHub sign-in', + ); + } + }; + + return ( + // Matched the button with the GoogleSignIn component + + ); +} \ No newline at end of file diff --git a/frontend/src/components/home/icons.tsx b/frontend/src/components/home/icons.tsx index 7e4e693a..44612fd8 100644 --- a/frontend/src/components/home/icons.tsx +++ b/frontend/src/components/home/icons.tsx @@ -2461,4 +2461,25 @@ export const Icons = { ), -}; + github: ({ + className, + color = 'currentColor', + }: { + className?: string; + color?: string; + }) => ( + + ), +}; \ No newline at end of file diff --git a/frontend/src/components/home/sections/hero-section.tsx b/frontend/src/components/home/sections/hero-section.tsx index e938eccc..de2e9d08 100644 --- a/frontend/src/components/home/sections/hero-section.tsx +++ b/frontend/src/components/home/sections/hero-section.tsx @@ -31,6 +31,7 @@ import { useAccounts } from '@/hooks/use-accounts'; import { isLocalMode, config } from '@/lib/config'; import { toast } from 'sonner'; import { useModal } from '@/hooks/use-modal-store'; +import GitHubSignIn from '@/components/GithubSignIn'; import { ChatInput, ChatInputHandles } from '@/components/thread/chat-input/chat-input'; import { normalizeFilenameToNFC } from '@/lib/utils/unicode'; import { createQueryHook } from '@/hooks/use-query'; @@ -354,9 +355,10 @@ export function HeroSection() { - {/* Google Sign In */} + {/* OAuth Sign In */}
+
{/* Divider */} diff --git a/frontend/src/components/thread/chat-input/_use-model-selection.ts b/frontend/src/components/thread/chat-input/_use-model-selection.ts index 7d06cf8e..7687e1ed 100644 --- a/frontend/src/components/thread/chat-input/_use-model-selection.ts +++ b/frontend/src/components/thread/chat-input/_use-model-selection.ts @@ -28,139 +28,78 @@ export interface CustomModel { label: string; } -// SINGLE SOURCE OF TRUTH for all model data +// SINGLE SOURCE OF TRUTH for all model data - aligned with backend constants export const MODELS = { - // Premium high-priority models + // Free tier models (available to all users) 'claude-sonnet-4': { tier: 'free', priority: 100, recommended: true, - lowQuality: false, - description: 'Claude Sonnet 4 - Anthropic\'s latest and most advanced AI assistant' + lowQuality: false + }, + + 'gemini-flash-2.5': { + tier: 'free', + priority: 70, + recommended: false, + lowQuality: false + }, + 'qwen3': { + tier: 'free', + priority: 60, + recommended: false, + lowQuality: false + }, + + // Premium/Paid tier models (require subscription) + 'sonnet-3.7': { + tier: 'premium', + priority: 99, + recommended: false, + lowQuality: false + }, + 'grok-4': { + tier: 'premium', + priority: 98, + recommended: false, + lowQuality: false }, 'google/gemini-2.5-pro': { tier: 'premium', - priority: 100, + priority: 97, recommended: false, - lowQuality: false, - description: 'Gemini Pro 2.5 - Google\'s latest advanced model' - }, - 'sonnet-3.7': { - tier: 'premium', - priority: 95, - recommended: false, - lowQuality: false, - description: 'Claude 3.7 - Anthropic\'s most powerful AI assistant' - }, - 'claude-sonnet-3.7-reasoning': { - tier: 'premium', - priority: 95, - recommended: true, - lowQuality: false, - description: 'Claude 3.7 with enhanced reasoning capabilities' + lowQuality: false }, 'gpt-4.1': { tier: 'premium', - priority: 95, + priority: 96, recommended: false, - lowQuality: false, - description: 'GPT-4.1 - OpenAI\'s most advanced model with enhanced reasoning' + lowQuality: false }, - 'claude-3.5': { - tier: 'premium', - priority: 90, - recommended: true, - lowQuality: false, - description: 'Claude 3.5 - Anthropic\'s balanced model with solid capabilities' - }, - 'gemini-2.5-flash:thinking': { + 'sonnet-3.5': { tier: 'premium', priority: 90, recommended: false, - lowQuality: false, - description: 'Gemini Flash 2.5 - Google\'s fast, responsive AI model' + lowQuality: false }, 'gpt-4o': { tier: 'premium', - priority: 85, + priority: 88, recommended: false, - lowQuality: false, - description: 'GPT-4o - Optimized for speed, reliability, and cost-effectiveness' + lowQuality: false }, - 'gpt-4-turbo': { + 'gemini-2.5-flash:thinking': { tier: 'premium', - priority: 85, + priority: 84, recommended: false, - lowQuality: false, - description: 'GPT-4 Turbo - OpenAI\'s powerful model with a great balance of performance and cost' - }, - 'gpt-4': { - tier: 'premium', - priority: 80, - recommended: false, - lowQuality: false, - description: 'GPT-4 - OpenAI\'s highly capable model with advanced reasoning' + lowQuality: false }, 'deepseek/deepseek-chat-v3-0324': { tier: 'premium', priority: 75, recommended: false, - lowQuality: false, - description: 'DeepSeek Chat - Advanced AI assistant with strong reasoning' + lowQuality: false }, - - // Free tier models - 'deepseek-r1': { - tier: 'free', - priority: 60, - recommended: false, - lowQuality: false, - description: 'DeepSeek R1 - Advanced model with enhanced reasoning and coding capabilities' - }, - 'deepseek': { - tier: 'free', - priority: 50, - recommended: false, - lowQuality: true, - description: 'DeepSeek - Free tier model with good general capabilities' - }, - 'gemini-flash-2.5': { - tier: 'free', - priority: 50, - recommended: false, - lowQuality: true, - description: 'Gemini Flash - Google\'s faster, more efficient model' - }, - 'grok-3-mini': { - tier: 'free', - priority: 45, - recommended: false, - lowQuality: true, - description: 'Grok-3 Mini - Smaller, faster version of Grok-3 for simpler tasks' - }, - 'qwen3': { - tier: 'free', - priority: 40, - recommended: false, - lowQuality: true, - description: 'Qwen3 - Alibaba\'s powerful multilingual language model' - }, -}; - -// Model tier definitions -export const MODEL_TIERS = { - premium: { - requiresSubscription: true, - baseDescription: 'Advanced model with superior capabilities' - }, - free: { - requiresSubscription: false, - baseDescription: 'Available to all users' - }, - custom: { - requiresSubscription: false, - baseDescription: 'User-defined model' - } }; // Helper to check if a user can access a model based on subscription status @@ -224,6 +163,7 @@ const saveModelPreference = (modelId: string): void => { export const useModelSelection = () => { const [selectedModel, setSelectedModel] = useState(DEFAULT_FREE_MODEL_ID); const [customModels, setCustomModels] = useState([]); + const [hasInitialized, setHasInitialized] = useState(false); const { data: subscriptionData } = useSubscription(); const { data: modelsData, isLoading: isLoadingModels } = useAvailableModels({ @@ -258,14 +198,12 @@ export const useModelSelection = () => { id: DEFAULT_FREE_MODEL_ID, label: 'DeepSeek', requiresSubscription: false, - description: MODELS[DEFAULT_FREE_MODEL_ID]?.description || MODEL_TIERS.free.baseDescription, priority: MODELS[DEFAULT_FREE_MODEL_ID]?.priority || 50 }, { id: DEFAULT_PREMIUM_MODEL_ID, - label: 'Claude Sonnet 4', + label: 'Sonnet 4', requiresSubscription: true, - description: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.description || MODEL_TIERS.premium.baseDescription, priority: MODELS[DEFAULT_PREMIUM_MODEL_ID]?.priority || 100 }, ]; @@ -295,8 +233,6 @@ export const useModelSelection = () => { id: shortName, label: cleanLabel, requiresSubscription: isPremium, - description: modelData.description || - (isPremium ? MODEL_TIERS.premium.baseDescription : MODEL_TIERS.free.baseDescription), top: modelData.priority >= 90, // Mark high-priority models as "top" priority: modelData.priority || 0, lowQuality: modelData.lowQuality || false, @@ -311,7 +247,6 @@ export const useModelSelection = () => { id: model.id, label: model.label || formatModelName(model.id), requiresSubscription: false, - description: MODEL_TIERS.custom.baseDescription, top: false, isCustom: true, priority: 30, // Low priority by default @@ -323,13 +258,13 @@ export const useModelSelection = () => { } // Sort models consistently in one place: - // 1. First by free/premium (free first) + // 1. First by recommended (recommended first) // 2. Then by priority (higher first) // 3. Finally by name (alphabetical) const sortedModels = models.sort((a, b) => { - // First by free/premium status - if (a.requiresSubscription !== b.requiresSubscription) { - return a.requiresSubscription ? -1 : 1; + // First by recommended status + if (a.recommended !== b.recommended) { + return a.recommended ? -1 : 1; } // Then by priority (higher first) @@ -352,66 +287,64 @@ export const useModelSelection = () => { ); }, [MODEL_OPTIONS, subscriptionStatus]); - // Initialize selected model from localStorage or defaults + // Initialize selected model from localStorage ONLY ONCE useEffect(() => { - if (typeof window === 'undefined') return; + if (typeof window === 'undefined' || hasInitialized) return; + + console.log('Initializing model selection from localStorage...'); try { const savedModel = localStorage.getItem(STORAGE_KEY_MODEL); + console.log('Saved model from localStorage:', savedModel); - // Local mode - allow any model - if (isLocalMode()) { - if (savedModel && MODEL_OPTIONS.find(option => option.id === savedModel)) { - setSelectedModel(savedModel); - } else { - setSelectedModel(DEFAULT_PREMIUM_MODEL_ID); - saveModelPreference(DEFAULT_PREMIUM_MODEL_ID); - } - return; - } - - // Premium subscription - ALWAYS use premium model - if (subscriptionStatus === 'active') { - // If they had a premium model saved and it's still valid, use it - const hasSavedPremiumModel = savedModel && - MODEL_OPTIONS.find(option => - option.id === savedModel && - option.requiresSubscription && - canAccessModel(subscriptionStatus, true) - ); - - // Otherwise use the default premium model - if (hasSavedPremiumModel) { - setSelectedModel(savedModel!); - } else { - setSelectedModel(DEFAULT_PREMIUM_MODEL_ID); - saveModelPreference(DEFAULT_PREMIUM_MODEL_ID); - } - return; - } - - // No subscription - use saved model if accessible (free tier), or default free + // If we have a saved model, validate it's still available and accessible if (savedModel) { - const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); - if (modelOption && canAccessModel(subscriptionStatus, modelOption.requiresSubscription)) { - setSelectedModel(savedModel); - } else { - setSelectedModel(DEFAULT_FREE_MODEL_ID); - saveModelPreference(DEFAULT_FREE_MODEL_ID); + // Wait for models to load before validating + if (isLoadingModels) { + console.log('Models still loading, waiting...'); + return; + } + + const modelOption = MODEL_OPTIONS.find(option => option.id === savedModel); + const isCustomModel = isLocalMode() && customModels.some(model => model.id === savedModel); + + // Check if saved model is still valid and accessible + if (modelOption || isCustomModel) { + const isAccessible = isLocalMode() || + canAccessModel(subscriptionStatus, modelOption?.requiresSubscription ?? false); + + if (isAccessible) { + console.log('Using saved model:', savedModel); + setSelectedModel(savedModel); + setHasInitialized(true); + return; + } else { + console.log('Saved model not accessible, falling back to default'); + } + } else { + console.log('Saved model not found in available models, falling back to default'); } - } else { - setSelectedModel(DEFAULT_FREE_MODEL_ID); - saveModelPreference(DEFAULT_FREE_MODEL_ID); } + + // Fallback to default model + const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; + console.log('Using default model:', defaultModel); + setSelectedModel(defaultModel); + saveModelPreference(defaultModel); + setHasInitialized(true); + } catch (error) { console.warn('Failed to load preferences from localStorage:', error); - setSelectedModel(DEFAULT_FREE_MODEL_ID); + const defaultModel = subscriptionStatus === 'active' ? DEFAULT_PREMIUM_MODEL_ID : DEFAULT_FREE_MODEL_ID; + setSelectedModel(defaultModel); + saveModelPreference(defaultModel); + setHasInitialized(true); } - }, [subscriptionStatus, MODEL_OPTIONS]); + }, [subscriptionStatus, MODEL_OPTIONS, isLoadingModels, customModels, hasInitialized]); // Handle model selection change const handleModelChange = (modelId: string) => { - console.log('handleModelChange', modelId); + console.log('handleModelChange called with:', modelId); // Refresh custom models from localStorage to ensure we have the latest if (isLocalMode()) { @@ -441,7 +374,8 @@ export const useModelSelection = () => { console.warn('Model not accessible:', modelId); return; } - console.log('setting selected model', modelId); + + console.log('Setting selected model and saving to localStorage:', modelId); setSelectedModel(modelId); saveModelPreference(modelId); };