diff --git a/app/config.py b/app/config.py index 52caf16..b7d5989 100644 --- a/app/config.py +++ b/app/config.py @@ -1,5 +1,6 @@ from pydantic_settings import BaseSettings -from typing import Optional, List +from typing import List, Optional +import os class Settings(BaseSettings): @@ -52,12 +53,121 @@ class Settings(BaseSettings): settings = Settings() -# Plan limits + +# ═══════════════════════════════════════════════════════════════════════════════ +# MODEL CATALOG — Single source of truth for all model metadata +# ═══════════════════════════════════════════════════════════════════════════════ +# To add a new model: +# 1. Add it here with name/provider/badge/description +# 2. Add its model_id → provider mapping in MODEL_PROVIDERS +# 3. Add it to the appropriate plan(s) in PLAN_LIMITS +# That's it — the frontend loads everything from GET /api/v1/models/available +# ═══════════════════════════════════════════════════════════════════════════════ + +MODEL_CATALOG = { + # ── Free tier (Fireworks - lightweight) ──────────────────────────────────── + "accounts/fireworks/models/kimi-k2-instruct-0905": { + "name": "Kimi K2", + "provider": "Fireworks AI", + "badge": "Free", + "description": "Free model for building and testing chatbots", + }, + + # ── Starter tier (Fireworks - powerful) ──────────────────────────────────── + "accounts/fireworks/models/llama-v3p1-70b-instruct": { + "name": "Llama 3.1 70B", + "provider": "Fireworks AI", + "badge": "Fast", + "description": "Fast open-source model, great for most tasks", + }, + "accounts/fireworks/models/mixtral-8x7b-instruct": { + "name": "Mixtral 8x7B", + "provider": "Fireworks AI", + "badge": "Balanced", + "description": "Balanced speed and quality", + }, + "accounts/fireworks/models/qwen2p5-72b-instruct": { + "name": "Qwen 2.5 72B", + "provider": "Fireworks AI", + "badge": "Multilingual", + "description": "Excellent multilingual capabilities", + }, + + # ── Pro tier (Premium providers) ─────────────────────────────────────────── + "gpt-4o": { + "name": "GPT-4o", + "provider": "OpenAI", + "badge": "Powerful", + "description": "Most capable OpenAI model", + }, + "gpt-4-turbo": { + "name": "GPT-4 Turbo", + "provider": "OpenAI", + "badge": "Smart", + "description": "Fast and capable with large context", + }, + "gpt-3.5-turbo": { + "name": "GPT-3.5 Turbo", + "provider": "OpenAI", + "badge": "Efficient", + "description": "Cost-effective for simpler tasks", + }, + "claude-3-5-sonnet-20241022": { + "name": "Claude 3.5 Sonnet", + "provider": "Anthropic", + "badge": "Reasoning", + "description": "Excellent at analysis and reasoning", + }, + "claude-3-opus-20240229": { + "name": "Claude 3 Opus", + "provider": "Anthropic", + "badge": "Advanced", + "description": "Most capable Anthropic model", + }, + "gemini-1.5-pro": { + "name": "Gemini 1.5 Pro", + "provider": "Google", + "badge": "Long Context", + "description": "Handles very long documents well", + }, +} + + +# ─── Model ID → LLM provider mapping (used by llm_client.py for routing) ───── + +MODEL_PROVIDERS = { + "accounts/fireworks/models/kimi-k2-instruct-0905": "fireworks", + "accounts/fireworks/models/llama-v3p1-70b-instruct": "fireworks", + "accounts/fireworks/models/mixtral-8x7b-instruct": "fireworks", + "accounts/fireworks/models/qwen2p5-72b-instruct": "fireworks", + "gpt-4o": "openai", + "gpt-4-turbo": "openai", + "gpt-3.5-turbo": "openai", + "claude-3-5-sonnet-20241022": "anthropic", + "claude-3-opus-20240229": "anthropic", + "gemini-1.5-pro": "google", +} + + +# ─── Default model per plan (pre-selected in the frontend) ──────────────────── + +DEFAULT_MODELS = { + "free": "accounts/fireworks/models/kimi-k2-instruct-0905", + "starter": "accounts/fireworks/models/llama-v3p1-70b-instruct", + "pro": "gpt-4o", + "enterprise": "gpt-4o", +} + + +# ─── Plan limits ────────────────────────────────────────────────────────────── + PLAN_LIMITS = { "free": { - "max_chatbots": 999999, # unlimited creation - "max_published": 0, # cannot publish - "models": [], + "max_chatbots": 999999, # unlimited creation + "max_published": 0, # cannot publish + "models": [ + "accounts/fireworks/models/kimi-k2-instruct-0905", + ], "conversations_limit": 999999, # unlimited preview "code_export": False, "features": ["preview_mode", "testing"], @@ -67,8 +177,9 @@ PLAN_LIMITS = { "max_published": 1, "models": [ "accounts/fireworks/models/kimi-k2-instruct-0905", - "accounts/fireworks/models/deepseek-v3p2", - "accounts/fireworks/models/glm-4p7", + "accounts/fireworks/models/llama-v3p1-70b-instruct", + "accounts/fireworks/models/mixtral-8x7b-instruct", + "accounts/fireworks/models/qwen2p5-72b-instruct", ], "conversations_limit": 5000, "code_export": False, @@ -79,7 +190,8 @@ PLAN_LIMITS = { "max_published": 3, "models": [ "accounts/fireworks/models/kimi-k2-instruct-0905", - "accounts/fireworks/models/deepseek-v3p2", + "accounts/fireworks/models/llama-v3p1-70b-instruct", + "accounts/fireworks/models/mixtral-8x7b-instruct", "gpt-4o", "gpt-4-turbo", "gpt-3.5-turbo", @@ -101,27 +213,9 @@ PLAN_LIMITS = { "enterprise": { "max_chatbots": 999999, "max_published": 999999, - "models": ["*"], + "models": ["*"], # resolves to all MODEL_CATALOG keys "conversations_limit": 999999, "code_export": True, "features": ["*"], }, -} - -MODEL_PROVIDERS = { - "accounts/fireworks/models/kimi-k2-instruct-0905": "fireworks", - "accounts/fireworks/models/deepseek-v3p2": "fireworks", - "accounts/fireworks/models/glm-4p7": "fireworks", - "gpt-4o": "openai", - "gpt-4-turbo": "openai", - "gpt-3.5-turbo": "openai", - "claude-3-5-sonnet-20241022": "anthropic", - "claude-3-opus-20240229": "anthropic", - "gemini-1.5-pro": "google", -} - -DEFAULT_MODELS = { - "starter": "accounts/fireworks/models/kimi-k2-instruct-0905", - "pro": "gpt-4o", - "enterprise": "gpt-4o", -} +} \ No newline at end of file diff --git a/app/main.py b/app/main.py index a66ea1f..5db3c5a 100644 --- a/app/main.py +++ b/app/main.py @@ -5,7 +5,7 @@ from fastapi.responses import JSONResponse import logging from app.config import settings -from app.routers import auth, chatbots, documents, chat, marketplace, billing +from app.routers import auth, chatbots, documents, chat, marketplace, billing, models # Configure logging logging.basicConfig( @@ -53,6 +53,7 @@ app.include_router(documents.router, prefix="/api/v1") app.include_router(chat.router, prefix="/api/v1") app.include_router(marketplace.router, prefix="/api/v1") app.include_router(billing.router, prefix="/api/v1") +app.include_router(models.router, prefix="/api/v1") # ── Health & Info ────────────────────────────────────────────────────────────── @app.get("/") diff --git a/app/models.py b/app/models.py index 75bff52..ef7ee5b 100644 --- a/app/models.py +++ b/app/models.py @@ -103,6 +103,7 @@ class ChatbotCreate(BaseModel): max_tokens: int = Field(default=1000, ge=100, le=8000) primary_color: str = "#6366f1" welcome_message: str = "Hello! How can I help you today?" + logo_url: Optional[str] = None category: Optional[str] = None industry: Optional[str] = None languages: List[str] = ["en"] @@ -117,6 +118,7 @@ class ChatbotUpdate(BaseModel): max_tokens: Optional[int] = None primary_color: Optional[str] = None welcome_message: Optional[str] = None + logo_url: Optional[str] = None category: Optional[str] = None industry: Optional[str] = None languages: Optional[List[str]] = None @@ -133,6 +135,7 @@ class ChatbotResponse(BaseModel): max_tokens: int primary_color: str welcome_message: str + logo_url: Optional[str] = None category: Optional[str] = None industry: Optional[str] = None languages: List[str] @@ -156,6 +159,7 @@ class ChatbotPublicResponse(BaseModel): languages: List[str] primary_color: str welcome_message: str + logo_url: Optional[str] = None average_rating: Optional[float] = None total_conversations: int = 0 company_name: Optional[str] = None @@ -303,4 +307,4 @@ class SuccessResponse(BaseModel): class ErrorResponse(BaseModel): error: str - detail: Optional[str] = None + detail: Optional[str] = None \ No newline at end of file diff --git a/app/routers/chatbots.py b/app/routers/chatbots.py index d7cbe58..40b02c4 100644 --- a/app/routers/chatbots.py +++ b/app/routers/chatbots.py @@ -53,7 +53,6 @@ async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)): vector_store.create_collection(collection_name) except Exception as e: logger.error(f"Failed to create Qdrant collection: {e}") - # Continue without vector store for now collection_name = None chatbot_data = { @@ -67,6 +66,7 @@ async def create_chatbot(data: ChatbotCreate, user=Depends(get_current_user)): "max_tokens": data.max_tokens, "primary_color": data.primary_color, "welcome_message": data.welcome_message, + "logo_url": data.logo_url, "category": data.category, "industry": data.industry, "languages": data.languages, @@ -227,6 +227,7 @@ def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse: max_tokens=chatbot.get("max_tokens", 1000), primary_color=chatbot.get("primary_color", "#6366f1"), welcome_message=chatbot.get("welcome_message", "Hello! How can I help?"), + logo_url=chatbot.get("logo_url"), category=chatbot.get("category"), industry=chatbot.get("industry"), languages=chatbot.get("languages", ["en"]), @@ -237,4 +238,4 @@ def _format_chatbot(chatbot: dict, supabase) -> ChatbotResponse: conversation_count=conv_count.count or 0, created_at=chatbot.get("created_at"), published_at=chatbot.get("published_at"), - ) + ) \ No newline at end of file diff --git a/app/routers/marketplace.py b/app/routers/marketplace.py index 09e0e34..23a6289 100644 --- a/app/routers/marketplace.py +++ b/app/routers/marketplace.py @@ -60,6 +60,7 @@ async def list_marketplace_chatbots( languages=c.get("languages", ["en"]), primary_color=c.get("primary_color", "#6366f1"), welcome_message=c.get("welcome_message", "Hello!"), + logo_url=c.get("logo_url"), average_rating=c.get("average_rating"), total_conversations=c.get("total_conversations", 0), company_name=company_data.get("name"), @@ -74,23 +75,27 @@ async def list_marketplace_chatbots( total=total, page=page, limit=limit, - has_more=(offset + limit) < total, + has_more=(offset + limit < total), ) @router.get("/chatbots/{chatbot_id}", response_model=ChatbotPublicResponse) -async def get_marketplace_chatbot(chatbot_id: str): +async def get_marketplace_chatbot(chatbot_id: str, user=Depends(get_optional_user)): supabase = get_supabase() - result = supabase.table("chatbots").select("*, companies(name, logo_url)") \ - .eq("id", chatbot_id) \ - .eq("is_published", True) \ - .execute() + + result = supabase.table("chatbots").select( + "*, companies(name, logo_url)" + ).eq("id", chatbot_id).eq("is_published", True).execute() if not result.data: - raise HTTPException(status_code=404, detail="Chatbot not found in marketplace") + raise HTTPException(status_code=404, detail="Chatbot not found") c = result.data[0] company_data = c.get("companies") or {} + + conv_count = supabase.table("conversations").select("id", count="exact") \ + .eq("chatbot_id", chatbot_id).execute() + return ChatbotPublicResponse( id=c["id"], name=c["name"], @@ -100,8 +105,9 @@ async def get_marketplace_chatbot(chatbot_id: str): languages=c.get("languages", ["en"]), primary_color=c.get("primary_color", "#6366f1"), welcome_message=c.get("welcome_message", "Hello!"), + logo_url=c.get("logo_url"), average_rating=c.get("average_rating"), - total_conversations=c.get("total_conversations", 0), + total_conversations=conv_count.count or 0, company_name=company_data.get("name"), company_logo=company_data.get("logo_url"), created_at=c.get("created_at"), @@ -130,4 +136,4 @@ async def rate_chatbot( new_avg = (current + rating.rating) / 2 supabase.table("chatbots").update({"average_rating": round(new_avg, 1)}).eq("id", chatbot_id).execute() - return {"message": "Rating submitted", "new_average": round(new_avg, 1)} + return {"message": "Rating submitted", "new_average": round(new_avg, 1)} \ No newline at end of file diff --git a/app/routers/models.py b/app/routers/models.py new file mode 100644 index 0000000..680da57 --- /dev/null +++ b/app/routers/models.py @@ -0,0 +1,107 @@ +""" +Models router - serves available AI models based on user subscription plan. + +Single source of truth flow: + config.py (PLAN_LIMITS + MODEL_CATALOG) → this router → frontend + +To add/remove/rename a model, only edit config.py. +""" +from fastapi import APIRouter, Depends +from app.dependencies import get_current_user +from app.database import get_supabase +from app.config import PLAN_LIMITS, MODEL_CATALOG, DEFAULT_MODELS +from typing import List, Optional +from pydantic import BaseModel +import logging + +logger = logging.getLogger(__name__) +router = APIRouter(prefix="/models", tags=["Models"]) + + +# ─── Response Models ─────────────────────────────────────────────────────────── + +class AvailableModel(BaseModel): + id: str + name: str + provider: str + badge: str + description: Optional[str] = None + is_default: bool = False + + +class ModelsResponse(BaseModel): + models: List[AvailableModel] + plan: str + default_model: Optional[str] = None + has_premium_access: bool + upgrade_label: Optional[str] = None + + +# ─── Helpers ─────────────────────────────────────────────────────────────────── + +def _get_user_plan(user_id: str) -> str: + """Get user's current subscription plan.""" + supabase = get_supabase() + result = supabase.table("subscriptions") \ + .select("plan") \ + .eq("user_id", user_id) \ + .eq("status", "active") \ + .execute() + return result.data[0]["plan"] if result.data else "free" + + +# ─── Endpoint ───────────────────────────────────────────────────────────────── + +@router.get("/available", response_model=ModelsResponse) +async def get_available_models(user=Depends(get_current_user)): + """ + Returns the list of AI models the user can access based on their plan. + Frontend uses this to populate model selection dynamically. + + - free: gets a default model for preview/testing + - starter: Fireworks AI models + - pro: Fireworks + OpenAI + Anthropic + Google + - enterprise: all models + """ + plan = _get_user_plan(user.id) + plan_config = PLAN_LIMITS.get(plan, PLAN_LIMITS["free"]) + allowed_model_ids = plan_config.get("models", []) + + # Enterprise wildcard → resolve to all catalog models + if "*" in allowed_model_ids: + allowed_model_ids = list(MODEL_CATALOG.keys()) + + # Build model list from catalog metadata + default_model_id = DEFAULT_MODELS.get(plan) + models: List[AvailableModel] = [] + + for model_id in allowed_model_ids: + meta = MODEL_CATALOG.get(model_id) + if not meta: + logger.warning(f"Model '{model_id}' in PLAN_LIMITS[{plan}] but not in MODEL_CATALOG — skipping") + continue + + models.append(AvailableModel( + id=model_id, + name=meta["name"], + provider=meta["provider"], + badge=meta["badge"], + description=meta.get("description"), + is_default=(model_id == default_model_id), + )) + + # Determine upgrade messaging + has_premium = plan in ("pro", "enterprise") + upgrade_label = None + if plan == "free": + upgrade_label = "Upgrade to Starter for more models and publishing" + elif plan == "starter": + upgrade_label = "Upgrade to Pro for GPT-4o, Claude, Gemini" + + return ModelsResponse( + models=models, + plan=plan, + default_model=default_model_id, + has_premium_access=has_premium, + upgrade_label=upgrade_label, + ) \ No newline at end of file