Merge branch 'main' into flow-improvement

This commit is contained in:
Saumya 2025-07-11 09:17:03 +05:30
commit daa0f81275
11 changed files with 749 additions and 389 deletions

View File

@ -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.

View File

@ -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)

View File

@ -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"

View File

@ -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,
}

View File

@ -639,7 +639,7 @@ export default function ThreadPage({
<div
className={cn(
"fixed bottom-0 z-10 bg-gradient-to-t from-background via-background/90 to-transparent px-4 pt-8 transition-all duration-200 ease-in-out",
leftSidebarState === 'expanded' ? 'left-[72px] lg:left-[256px]' : 'left-[72px]',
leftSidebarState === 'expanded' ? 'left-[72px] md:left-[256px]' : 'left-[72px]',
isSidePanelOpen ? 'right-[90%] sm:right-[450px] md:right-[500px] lg:right-[550px] xl:right-[650px]' : 'right-0',
isMobile ? 'left-0 right-0' : ''
)}>

View File

@ -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<string>('');
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 (
<main className="flex flex-col items-center justify-center h-screen bg-background p-8">
<div className="flex flex-col items-center gap-4 text-center max-w-sm">
{status !== 'error' && (
<Loader2 className="h-8 w-8 animate-spin text-primary" />
)}
<div className="space-y-2">
<h1 className="text-lg font-medium">GitHub Sign-In</h1>
<p className={`text-sm ${getStatusColor()}`}>{getStatusMessage()}</p>
</div>
{status === 'error' && (
<button
onClick={() => window.close()}
className="mt-4 px-4 py-2 text-sm bg-primary text-primary-foreground rounded-lg hover:bg-primary/90 transition-colors"
>
Close
</button>
)}
</div>
</main>
);
}

View File

@ -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() {
</div>
)}
{/* Google Sign In */}
<div className="w-full">
<GoogleSignIn returnUrl={returnUrl || undefined} />
{/* OAuth Sign In */}
<div className="w-full flex flex-col gap-3 mb-6">
<div className="w-full">
<GoogleSignIn returnUrl={returnUrl || undefined} />
</div>
<div className="w-full">
<GitHubSignIn returnUrl={returnUrl || undefined} />
</div>
</div>
{/* Divider */}
<div className="relative my-8">
<div className="absolute inset-0 flex items-center">

View File

@ -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<AuthMessage>) => {
// 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
<button
onClick={handleGitHubSignIn}
disabled={isLoading}
className="relative w-full h-12 flex items-center justify-center text-sm font-normal tracking-wide rounded-full bg-background text-foreground border border-border hover:bg-accent/30 transition-all duration-200 disabled:opacity-60 disabled:cursor-not-allowed font-sans"
aria-label={
isLoading ? 'Signing in with GitHub...' : 'Sign in with GitHub'
}
type="button"
>
<div className="absolute left-0 inset-y-0 flex items-center pl-1 w-10">
<div className="w-8 h-8 rounded-full flex items-center justify-center text-foreground dark:bg-foreground dark:text-background">
{isLoading ? (
<div className="w-5 h-5 border-2 border-current border-t-transparent rounded-full animate-spin" />
) : (
<Icons.github className="w-5 h-5" />
)}
</div>
</div>
<span className="ml-9 font-light">
{isLoading ? 'Signing in...' : 'Continue with GitHub'}
</span>
</button>
);
}

View File

@ -2461,4 +2461,25 @@ export const Icons = {
</defs>
</svg>
),
};
github: ({
className,
color = 'currentColor',
}: {
className?: string;
color?: string;
}) => (
<svg
className={cn('w-9 h-9', className)}
viewBox="0 0 24 24"
aria-hidden="true"
xmlns="http://www.w3.org/2000/svg"
fill={color}
>
<path
fillRule="evenodd"
clipRule="evenodd"
d="M12 0C5.37 0 0 5.373 0 12.01c0 5.303 3.438 9.8 8.207 11.387.6.113.82-.26.82-.577v-2.256c-3.338.727-4.033-1.416-4.033-1.416-.546-1.39-1.333-1.76-1.333-1.76-1.09-.745.082-.729.082-.729 1.205.085 1.84 1.26 1.84 1.26 1.07 1.836 2.807 1.306 3.492.998.108-.775.42-1.307.763-1.606-2.665-.307-5.466-1.34-5.466-5.968 0-1.318.47-2.396 1.24-3.24-.125-.307-.537-1.545.116-3.22 0 0 1.008-.324 3.3 1.23a11.44 11.44 0 013.006-.404c1.02.005 2.047.137 3.006.404 2.29-1.554 3.297-1.23 3.297-1.23.655 1.675.243 2.913.12 3.22.77.844 1.237 1.922 1.237 3.24 0 4.64-2.805 5.658-5.48 5.96.43.37.814 1.103.814 2.222v3.293c0 .32.216.694.825.577C20.565 21.807 24 17.31 24 12.01 24 5.373 18.627 0 12 0z"
/>
</svg>
),
};

View File

@ -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 */}
<div className="w-full">
<GoogleSignIn returnUrl="/dashboard" />
<GitHubSignIn returnUrl="/dashboard" />
</div>
{/* Divider */}

View File

@ -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<CustomModel[]>([]);
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);
};