Add in-product nudge for claude code feedback survey + new learning centre (#19303)

* fix(proxy_setting_endpoints.py): add new GET /in_product_nudges route

allows for context-based nudges

* feat: initial commit, adding in-product nudge for claude code usage

helps us talk to more litellm x claude code users

* fix: link out to google form for claude code in-product nudge

* feat(index.json): add new guide

* feat(index.json): add new claude code guides

* fix: link out

* fix: remove baselines
This commit is contained in:
Krish Dholakia
2026-01-18 09:35:20 +05:30
committed by GitHub
parent 2fda7a2534
commit 4fa1470fef
44 changed files with 485 additions and 124 deletions
+36
View File
@@ -95,4 +95,40 @@
"LiteLLM",
"Quickstart"
]
},
{
"title": "AI Coding Tool Usage Tracking",
"description": "This is a guide to tracking usage for AI coding tools monitor the use of Claude Code , Google Antigravity, OpenAI Codex, Roo Code etc. through LiteLLM.",
"url": "https://docs.litellm.ai/docs/tutorials/cost_tracking_coding",
"date": "2026-01-17",
"version": "1.0.0",
"tags": [
"Claude Code",
"Gemini CLI",
"OpenAI Codex",
"LiteLLM"
]
},
{
"title": "Use Web Search with Claude Code (across OpenAI/Anthropic/Gemini/etc.)",
"description": "This is a guide for using Web Search with Claude Code via LiteLLM.",
"url": "https://docs.litellm.ai/docs/tutorials/claude_code_websearch",
"date": "2026-01-17",
"version": "1.0.0",
"tags": [
"Claude Code",
"LiteLLM",
"Web Search"
]
},
{
"title": "Track Claude Code Usage per user via Custom Headers",
"description": "This is a guide for tracking claude code user usage by passing a customer ID header.",
"url": "https://docs.litellm.ai/docs/tutorials/claude_code_customer_tracking",
"date": "2026-01-17",
"version": "1.0.0",
"tags": [
"Claude Code",
"LiteLLM"
]
}]
@@ -1,2 +0,0 @@
-- This is an empty migration.
@@ -1,2 +0,0 @@
-- This is an empty migration.
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
@@ -1,5 +1,6 @@
#### Analytics Endpoints #####
import os
from fastapi import APIRouter
from litellm.types.proxy.discovery_endpoints.ui_discovery_endpoints import (
@@ -14,10 +15,12 @@ router = APIRouter()
"/litellm/.well-known/litellm-ui-config", response_model=UiDiscoveryEndpoints
) # if mounted at root path
async def get_ui_config():
from litellm.proxy.utils import get_proxy_base_url, get_server_root_path
from litellm.proxy.auth.auth_utils import _has_user_setup_sso
from litellm.proxy.utils import get_proxy_base_url, get_server_root_path
auto_redirect_ui_login_to_sso = os.getenv("AUTO_REDIRECT_UI_LOGIN_TO_SSO", "true").lower() == "true"
auto_redirect_ui_login_to_sso = (
os.getenv("AUTO_REDIRECT_UI_LOGIN_TO_SSO", "true").lower() == "true"
)
admin_ui_disabled = os.getenv("DISABLE_ADMIN_UI", "false").lower() == "true"
return UiDiscoveryEndpoints(
@@ -1,8 +1,8 @@
#### CRUD ENDPOINTS for UI Settings #####
import json
from typing import Any, Dict, List, Union, Optional
from typing import Any, Dict, List, Optional, Union
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File
from fastapi import APIRouter, Depends, File, HTTPException, UploadFile
import litellm
from litellm._logging import verbose_proxy_logger
@@ -10,6 +10,7 @@ from litellm.proxy._types import *
from litellm.proxy.auth.user_api_key_auth import user_api_key_auth
from litellm.types.proxy.management_endpoints.ui_sso import (
DefaultTeamSSOParams,
InProductNudgeResponse,
SSOConfig,
)
@@ -22,11 +23,11 @@ class IPAddress(BaseModel):
class UIThemeConfig(BaseModel):
"""Configuration for UI theme customization"""
# Logo configuration
logo_url: Optional[str] = Field(
default=None,
description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL"
description="URL or path to custom logo image. Can be a local file path or HTTP/HTTPS URL",
)
@@ -85,7 +86,10 @@ class UISettingsResponse(SettingsResponse):
# Allowlist of UI settings that can be stored
ALLOWED_UI_SETTINGS_FIELDS = {"disable_model_add_for_internal_users", "disable_team_admin_delete_team_user"}
ALLOWED_UI_SETTINGS_FIELDS = {
"disable_model_add_for_internal_users",
"disable_team_admin_delete_team_user",
}
@router.get(
@@ -434,7 +438,7 @@ async def get_sso_settings():
# Initialize with defaults
sso_settings_dict = {}
if sso_db_record and sso_db_record.sso_settings:
# Load settings from database
sso_settings_dict = dict(sso_db_record.sso_settings)
@@ -444,26 +448,43 @@ async def get_sso_settings():
role_mappings = None
if role_mappings_data:
from litellm.types.proxy.management_endpoints.ui_sso import RoleMappings
if isinstance(role_mappings_data, dict):
role_mappings = RoleMappings(**role_mappings_data)
elif isinstance(role_mappings_data, RoleMappings):
role_mappings = role_mappings_data
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(environment_variables=sso_settings_dict)
decrypted_sso_settings_dict = proxy_config._decrypt_and_set_db_env_variables(
environment_variables=sso_settings_dict
)
# Build SSO config with database values or environment fallback
sso_config = SSOConfig(
google_client_id=decrypted_sso_settings_dict.get("google_client_id", None),
google_client_secret=decrypted_sso_settings_dict.get("google_client_secret", None),
microsoft_client_id=decrypted_sso_settings_dict.get("microsoft_client_id", None),
microsoft_client_secret=decrypted_sso_settings_dict.get("microsoft_client_secret", None),
google_client_secret=decrypted_sso_settings_dict.get(
"google_client_secret", None
),
microsoft_client_id=decrypted_sso_settings_dict.get(
"microsoft_client_id", None
),
microsoft_client_secret=decrypted_sso_settings_dict.get(
"microsoft_client_secret", None
),
microsoft_tenant=decrypted_sso_settings_dict.get("microsoft_tenant", None),
generic_client_id=decrypted_sso_settings_dict.get("generic_client_id", None),
generic_client_secret=decrypted_sso_settings_dict.get("generic_client_secret", None),
generic_authorization_endpoint=decrypted_sso_settings_dict.get("generic_authorization_endpoint", None),
generic_token_endpoint=decrypted_sso_settings_dict.get("generic_token_endpoint", None),
generic_userinfo_endpoint=decrypted_sso_settings_dict.get("generic_userinfo_endpoint", None),
generic_client_secret=decrypted_sso_settings_dict.get(
"generic_client_secret", None
),
generic_authorization_endpoint=decrypted_sso_settings_dict.get(
"generic_authorization_endpoint", None
),
generic_token_endpoint=decrypted_sso_settings_dict.get(
"generic_token_endpoint", None
),
generic_userinfo_endpoint=decrypted_sso_settings_dict.get(
"generic_userinfo_endpoint", None
),
proxy_base_url=decrypted_sso_settings_dict.get("proxy_base_url", None),
user_email=decrypted_sso_settings_dict.get("user_email"),
ui_access_mode=decrypted_sso_settings_dict.get("ui_access_mode"),
@@ -506,10 +527,14 @@ async def update_sso_settings(sso_config: SSOConfig):
"""
Update SSO configuration by saving to the dedicated SSO table.
"""
import os
import json
import os
from litellm.proxy.proxy_server import prisma_client, store_model_in_db, proxy_config
from litellm.proxy.proxy_server import (
prisma_client,
proxy_config,
store_model_in_db,
)
if prisma_client is None:
raise HTTPException(
@@ -562,7 +587,9 @@ async def update_sso_settings(sso_config: SSOConfig):
# Clear environment variable if value is null/empty
os.environ.pop(env_var_name, None)
encrypted_sso_data = proxy_config._encrypt_env_variables(environment_variables=sso_data)
encrypted_sso_data = proxy_config._encrypt_env_variables(
environment_variables=sso_data
)
# Save to dedicated SSO table
await prisma_client.db.litellm_ssoconfig.upsert(
@@ -655,9 +682,10 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
Update UI theme configuration.
Updates logo settings for the admin UI.
"""
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
import os
from litellm.proxy.proxy_server import proxy_config, store_model_in_db
if store_model_in_db is not True:
raise HTTPException(
status_code=500,
@@ -668,28 +696,30 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
# Load existing config
config = await proxy_config.get_config()
# Update config with UI theme settings
if "general_settings" not in config:
config["general_settings"] = {}
if "environment_variables" not in config:
config["environment_variables"] = {}
# Convert theme config to dict
theme_data = theme_config.model_dump(exclude_none=True)
# Store UI theme config in litellm_settings (where it's retrieved from)
if "litellm_settings" not in config:
config["litellm_settings"] = {}
config["litellm_settings"]["ui_theme_config"] = theme_data
# Update UI_LOGO_PATH environment variable if logo_url is provided
# If logo_url is empty string, None, or null, remove the environment variable to use default
logo_url = theme_data.get("logo_url")
verbose_proxy_logger.debug(f"Updating logo_url: {logo_url}")
if logo_url and isinstance(logo_url, str) and logo_url.strip(): # Check if logo_url exists and is not empty/whitespace
if (
logo_url and isinstance(logo_url, str) and logo_url.strip()
): # Check if logo_url exists and is not empty/whitespace
config["environment_variables"]["UI_LOGO_PATH"] = logo_url
os.environ["UI_LOGO_PATH"] = logo_url
verbose_proxy_logger.debug(f"Set UI_LOGO_PATH to: {logo_url}")
@@ -704,12 +734,15 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
# Handle environment variable encryption if needed
stored_config = config.copy()
if "environment_variables" in stored_config and len(stored_config["environment_variables"]) > 0:
if (
"environment_variables" in stored_config
and len(stored_config["environment_variables"]) > 0
):
# Only encrypt if there are environment variables to encrypt
stored_config["environment_variables"] = proxy_config._encrypt_env_variables(
environment_variables=stored_config["environment_variables"]
)
# Save the updated config
await proxy_config.save_config(new_config=stored_config)
@@ -720,6 +753,34 @@ async def update_ui_theme_settings(theme_config: UIThemeConfig):
}
@router.get(
"/in_product_nudges",
tags=["UI Settings"],
dependencies=[Depends(user_api_key_auth)],
response_model=InProductNudgeResponse,
)
async def get_in_product_nudges():
"""
Get in-product nudges configuration.
"""
from litellm.proxy.proxy_server import prisma_client
if prisma_client is None:
raise HTTPException(
status_code=500,
detail={"error": "Database not connected. Please connect a database."},
)
db_record = await prisma_client.db.litellm_dailytagspend.find_first(
where={"tag": "User-Agent: claude-cli"}
)
if db_record:
return InProductNudgeResponse(is_claude_code_enabled=True)
return InProductNudgeResponse(is_claude_code_enabled=False)
@router.get(
"/get/ui_settings",
tags=["UI Settings"],
@@ -752,7 +813,9 @@ async def get_ui_settings():
ui_settings = dict(ui_settings_json)
# Sanitize any unexpected keys from persisted config before returning
ui_settings = {k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS}
ui_settings = {
k: v for k, v in ui_settings.items() if k in ALLOWED_UI_SETTINGS_FIELDS
}
# Build config-like object for schema helper
config: Dict[str, Any] = {"litellm_settings": {"ui_settings": ui_settings}}
@@ -823,6 +886,7 @@ async def update_ui_settings(
"settings": ui_settings,
}
@router.post(
"/upload/logo",
tags=["UI Theme Settings"],
@@ -839,35 +903,35 @@ async def upload_logo(file: UploadFile = File(...)):
# Validate file type
allowed_extensions = {".png", ".jpg", ".jpeg", ".svg"}
file_extension = Path(file.filename or "").suffix.lower()
if file_extension not in allowed_extensions:
raise HTTPException(
status_code=400,
detail=f"Invalid file type. Allowed types: {', '.join(allowed_extensions)}"
detail=f"Invalid file type. Allowed types: {', '.join(allowed_extensions)}",
)
# Validate file size (max 5MB)
file_content = await file.read()
if len(file_content) > 5 * 1024 * 1024: # 5MB
raise HTTPException(
status_code=400,
detail="File size too large. Maximum size is 5MB."
status_code=400, detail="File size too large. Maximum size is 5MB."
)
# Create uploads directory if it doesn't exist
current_dir = os.path.dirname(os.path.abspath(__file__))
upload_dir = os.path.join(current_dir, "..", "uploads")
os.makedirs(upload_dir, exist_ok=True)
# Generate unique filename
from litellm._uuid import uuid
unique_filename = f"logo_{uuid.uuid4().hex}{file_extension}"
file_path = os.path.join(upload_dir, unique_filename)
# Save the file
with open(file_path, "wb") as buffer:
buffer.write(file_content)
return {
"message": "Logo uploaded successfully",
"status": "success",
@@ -1,11 +1,10 @@
from typing import Dict, List, Literal, Optional, Union
from pydantic import Field
from pydantic import BaseModel, Field
from typing_extensions import TypedDict
from litellm.types.utils import LiteLLMPydanticObjectBase
from litellm.proxy._types import LitellmUserRoles
from litellm.types.utils import LiteLLMPydanticObjectBase
class LiteLLM_UpperboundKeyGenerateParams(LiteLLMPydanticObjectBase):
@@ -28,6 +27,7 @@ class LiteLLM_UpperboundKeyGenerateParams(LiteLLMPydanticObjectBase):
tpm_limit: Optional[int] = None
rpm_limit: Optional[int] = None
class MicrosoftGraphAPIUserGroupDirectoryObject(TypedDict, total=False):
"""Model for Microsoft Graph API directory object"""
@@ -65,7 +65,7 @@ class AccessControl_UI_AccessMode(LiteLLMPydanticObjectBase):
class RoleMappings(LiteLLMPydanticObjectBase):
"""
Configuration for mapping SSO groups to LiteLLM roles.
The system will look at the group_claim field in the SSO token to determine
which role to assign the user based on the roles mapping.
"""
@@ -78,11 +78,11 @@ class RoleMappings(LiteLLMPydanticObjectBase):
)
default_role: Optional[LitellmUserRoles] = Field(
default=None,
description="Default role to assign if user's groups don't match any role mappings. Must be a valid LitellmUserRoles value (e.g., 'proxy_admin', 'internal_user', 'proxy_admin_viewer')"
description="Default role to assign if user's groups don't match any role mappings. Must be a valid LitellmUserRoles value (e.g., 'proxy_admin', 'internal_user', 'proxy_admin_viewer')",
)
roles: Dict[LitellmUserRoles, List[str]] = Field(
default_factory=dict,
description="Mapping of LiteLLM role names to arrays of SSO group names. Example: {'proxy_admin': ['group-1', 'group-2'], 'proxy_admin_viewer': ['group-3']}"
description="Mapping of LiteLLM role names to arrays of SSO group names. Example: {'proxy_admin': ['group-1', 'group-2'], 'proxy_admin_viewer': ['group-3']}",
)
@@ -185,3 +185,10 @@ class DefaultTeamSSOParams(LiteLLMPydanticObjectBase):
default=None,
description="Default rpm limit for new automatically created teams",
)
class InProductNudgeResponse(BaseModel):
is_claude_code_enabled: bool = Field(
default=False,
description="Whether the Claude Code nudge should be shown.",
)
+71 -2
View File
@@ -17,7 +17,7 @@ import { Team } from "@/components/key_team_helpers/key_list";
import { MCPServers } from "@/components/mcp_tools";
import ModelHubTable from "@/components/AIHub/ModelHubTable";
import Navbar from "@/components/navbar";
import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName } from "@/components/networking";
import { getUiConfig, Organization, proxyBaseUrl, setGlobalLitellmHeaderName, getInProductNudgesCall } from "@/components/networking";
import NewUsagePage from "@/components/UsagePage/components/UsagePageView";
import OldTeams from "@/components/OldTeams";
import { fetchUserModels } from "@/components/organisms/create_key_button";
@@ -27,7 +27,7 @@ import PromptsPanel from "@/components/prompts";
import PublicModelHub from "@/components/public_model_hub";
import { SearchTools } from "@/components/search_tools";
import Settings from "@/components/settings";
import { SurveyPrompt, SurveyModal } from "@/components/survey";
import { SurveyPrompt, SurveyModal, ClaudeCodePrompt, ClaudeCodeModal } from "@/components/survey";
import TagManagement from "@/components/tag_management";
import TransformRequestPanel from "@/components/transform_request";
import UIThemeSettings from "@/components/ui_theme_settings";
@@ -124,6 +124,11 @@ export default function CreateKeyPage() {
const [showSurveyPrompt, setShowSurveyPrompt] = useState(true);
const [showSurveyModal, setShowSurveyModal] = useState(false);
// Claude Code feedback state
const [isClaudeCode, setIsClaudeCode] = useState(false);
const [showClaudeCodePrompt, setShowClaudeCodePrompt] = useState(false);
const [showClaudeCodeModal, setShowClaudeCodeModal] = useState(false);
const invitation_id = searchParams.get("invitation_id");
// Get page from URL, default to 'api-keys' if not present
@@ -267,6 +272,29 @@ export default function CreateKeyPage() {
}
}, [accessToken, userID, userRole]);
// Fetch in-product nudges configuration from backend
useEffect(() => {
if (accessToken && token) {
(async () => {
try {
const nudgesConfig = await getInProductNudgesCall(accessToken);
const isUsingClaudeCode = nudgesConfig?.is_claude_code_enabled || false;
setIsClaudeCode(isUsingClaudeCode);
// Show Claude Code prompt on login if enabled
if (isUsingClaudeCode) {
setShowClaudeCodePrompt(true);
// Don't show the regular survey prompt if showing Claude Code prompt
setShowSurveyPrompt(false);
}
} catch (error) {
console.error("Failed to fetch in-product nudges:", error);
// Silently fail and don't show Claude Code nudge
}
})();
}
}, [accessToken, token]);
// Auto-dismiss survey prompt after 15 seconds
useEffect(() => {
if (showSurveyPrompt && !showSurveyModal) {
@@ -277,6 +305,16 @@ export default function CreateKeyPage() {
}
}, [showSurveyPrompt, showSurveyModal]);
// Auto-dismiss Claude Code prompt after 15 seconds
useEffect(() => {
if (showClaudeCodePrompt && !showClaudeCodeModal) {
const timer = setTimeout(() => {
setShowClaudeCodePrompt(false);
}, 15000);
return () => clearTimeout(timer);
}
}, [showClaudeCodePrompt, showClaudeCodeModal]);
const handleOpenSurvey = () => {
setShowSurveyPrompt(false);
setShowSurveyModal(true);
@@ -296,6 +334,25 @@ export default function CreateKeyPage() {
setShowSurveyPrompt(true);
};
const handleOpenClaudeCode = () => {
setShowClaudeCodePrompt(false);
setShowClaudeCodeModal(true);
};
const handleDismissClaudeCodePrompt = () => {
setShowClaudeCodePrompt(false);
};
const handleClaudeCodeComplete = () => {
setShowClaudeCodeModal(false);
};
const handleClaudeCodeModalClose = () => {
// If they close the modal without completing, show the prompt again
setShowClaudeCodeModal(false);
setShowClaudeCodePrompt(true);
};
if (authLoading || redirectToLogin) {
return <LoadingScreen />;
}
@@ -503,6 +560,18 @@ export default function CreateKeyPage() {
onClose={handleSurveyModalClose}
onComplete={handleSurveyComplete}
/>
{/* Claude Code Components */}
<ClaudeCodePrompt
isVisible={showClaudeCodePrompt}
onOpen={handleOpenClaudeCode}
onDismiss={handleDismissClaudeCodePrompt}
/>
<ClaudeCodeModal
isOpen={showClaudeCodeModal}
onClose={handleClaudeCodeModalClose}
onComplete={handleClaudeCodeComplete}
/>
</div>
)}
</ThemeProvider>
@@ -7,6 +7,7 @@ import {
BarChartOutlined,
BgColorsOutlined,
BlockOutlined,
BookOutlined,
CreditCardOutlined,
DatabaseOutlined,
ExperimentOutlined,
@@ -47,6 +48,7 @@ interface MenuItem {
roles?: string[];
children?: MenuItem[];
icon?: React.ReactNode;
external_url?: string;
}
// Group configuration
@@ -213,6 +215,13 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
label: "AI Hub",
icon: <AppstoreOutlined />,
},
{
key: "learning-resources",
page: "learning-resources",
label: "Learning Resources",
icon: <BookOutlined />,
external_url: "https://models.litellm.ai/cookbook",
},
{
key: "experimental",
page: "experimental",
@@ -252,7 +261,7 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
page: "usage",
label: "Old Usage",
icon: <BarChartOutlined />,
},
}
],
},
],
@@ -364,9 +373,23 @@ const Sidebar: React.FC<SidebarProps> = ({ setPage, defaultSelectedKey, collapse
key: child.key,
icon: child.icon,
label: child.label,
onClick: () => navigateToPage(child.page),
onClick: () => {
if (child.external_url) {
window.open(child.external_url, "_blank");
} else {
navigateToPage(child.page);
}
},
})),
onClick: !item.children ? () => navigateToPage(item.page) : undefined,
onClick: !item.children
? () => {
if (item.external_url) {
window.open(item.external_url, "_blank");
} else {
navigateToPage(item.page);
}
}
: undefined,
})),
});
});
@@ -35,6 +35,36 @@ export const getCallbackConfigsCall = async (accessToken: string) => {
throw error;
}
};
export const getInProductNudgesCall = async (accessToken: string) => {
/**
* Get in-product nudges configuration.
*/
try {
let url = proxyBaseUrl ? `${proxyBaseUrl}/in_product_nudges` : `/in_product_nudges`;
const response = await fetch(url, {
method: "GET",
headers: {
[globalLitellmHeaderName]: `Bearer ${accessToken}`,
"Content-Type": "application/json",
},
});
if (!response.ok) {
const errorData = await response.json();
const errorMessage = deriveErrorMessage(errorData);
handleError(errorMessage);
throw new Error(errorMessage);
}
const data = await response.json();
return data;
} catch (error) {
console.error("Failed to get in-product nudges:", error);
throw error;
}
};
/**
* Helper file for calls being made to proxy
*/
@@ -0,0 +1,69 @@
import React from "react";
import { X, Code, ExternalLink } from "lucide-react";
import { Button } from "antd";
interface ClaudeCodeModalProps {
isOpen: boolean;
onClose: () => void;
onComplete: () => void;
}
const GOOGLE_FORM_URL = "https://forms.gle/LZeJQ3XytBakckYa9";
export function ClaudeCodeModal({ isOpen, onClose, onComplete }: ClaudeCodeModalProps) {
if (!isOpen) return null;
const handleOpenForm = () => {
window.open(GOOGLE_FORM_URL, "_blank", "noopener,noreferrer");
onComplete();
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6">
{/* Backdrop */}
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
{/* Modal */}
<div className="relative w-full max-w-md bg-white rounded-xl shadow-2xl overflow-hidden transform transition-all duration-300 ease-out">
{/* Header */}
<div className="px-6 py-4 border-b border-gray-100 flex items-center justify-between bg-gray-50/50">
<div className="flex items-center gap-2 text-purple-600">
<Code className="h-5 w-5" />
<span className="font-semibold text-sm tracking-wide uppercase">Claude Code Feedback</span>
</div>
<button
onClick={onClose}
className="text-gray-400 hover:text-gray-600 transition-colors p-1 rounded-full hover:bg-gray-100"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Content */}
<div className="p-8">
<h2 className="text-2xl font-bold text-gray-900 mb-4">
Help us improve your experience
</h2>
<p className="text-gray-600 mb-6">
We'd love to hear about your experience using LiteLLM with Claude Code. Your feedback helps us improve the product for everyone.
</p>
<p className="text-sm text-gray-500 mb-6">
This brief survey takes about 2-3 minutes to complete.
</p>
<Button
type="primary"
size="large"
block
onClick={handleOpenForm}
icon={<ExternalLink className="h-4 w-4" />}
style={{ backgroundColor: '#7c3aed', borderColor: '#7c3aed' }}
>
Open Feedback Form
</Button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,26 @@
import React from "react";
import { Code } from "lucide-react";
import { NudgePrompt } from "./NudgePrompt";
interface ClaudeCodePromptProps {
onOpen: () => void;
onDismiss: () => void;
isVisible: boolean;
}
export function ClaudeCodePrompt({ onOpen, onDismiss, isVisible }: ClaudeCodePromptProps) {
return (
<NudgePrompt
onOpen={onOpen}
onDismiss={onDismiss}
isVisible={isVisible}
title="Claude Code Feedback"
description="Help us improve your Claude Code experience with LiteLLM! Share your feedback in 4 quick questions."
buttonText="Share feedback"
icon={Code}
accentColor="#7c3aed"
buttonStyle={{ backgroundColor: '#7c3aed', borderColor: '#7c3aed' }}
/>
);
}
@@ -0,0 +1,91 @@
import React, { useEffect, useState } from "react";
import { X, LucideIcon } from "lucide-react";
import { Button } from "antd";
interface NudgePromptProps {
onOpen: () => void;
onDismiss: () => void;
isVisible: boolean;
title: string;
description: string;
buttonText: string;
icon: LucideIcon;
accentColor: string;
buttonStyle?: React.CSSProperties;
}
const DISMISS_DURATION = 15000; // 15 seconds
export function NudgePrompt({
onOpen,
onDismiss,
isVisible,
title,
description,
buttonText,
icon: Icon,
accentColor,
buttonStyle,
}: NudgePromptProps) {
const [progress, setProgress] = useState(100);
useEffect(() => {
if (!isVisible) {
setProgress(100);
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 100 - (elapsed / DISMISS_DURATION) * 100);
setProgress(remaining);
if (remaining <= 0) {
clearInterval(interval);
}
}, 50);
return () => clearInterval(interval);
}, [isVisible]);
if (!isVisible) return null;
return (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${
isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
{/* Progress bar at top showing time remaining */}
<div className="h-1 bg-gray-100 w-full">
<div
className="h-full transition-all duration-100 ease-linear"
style={{ width: `${progress}%`, backgroundColor: accentColor }}
/>
</div>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2" style={{ color: accentColor }}>
<Icon className="h-5 w-5" />
<span className="font-semibold text-sm">{title}</span>
</div>
<button
onClick={onDismiss}
className="text-gray-400 hover:text-gray-600 transition-colors p-0.5 rounded hover:bg-gray-100"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-600 mb-3">{description}</p>
<Button type="primary" block onClick={onOpen} style={buttonStyle}>
{buttonText}
</Button>
</div>
</div>
);
}
@@ -1,6 +1,6 @@
import React, { useEffect, useState } from "react";
import { MessageSquare, X } from "lucide-react";
import { Button } from "antd";
import React from "react";
import { MessageSquare } from "lucide-react";
import { NudgePrompt } from "./NudgePrompt";
interface SurveyPromptProps {
onOpen: () => void;
@@ -8,70 +8,18 @@ interface SurveyPromptProps {
isVisible: boolean;
}
const DISMISS_DURATION = 15000; // 15 seconds
export function SurveyPrompt({ onOpen, onDismiss, isVisible }: SurveyPromptProps) {
const [progress, setProgress] = useState(100);
useEffect(() => {
if (!isVisible) {
setProgress(100);
return;
}
const startTime = Date.now();
const interval = setInterval(() => {
const elapsed = Date.now() - startTime;
const remaining = Math.max(0, 100 - (elapsed / DISMISS_DURATION) * 100);
setProgress(remaining);
if (remaining <= 0) {
clearInterval(interval);
}
}, 50);
return () => clearInterval(interval);
}, [isVisible]);
if (!isVisible) return null;
return (
<div
className={`fixed bottom-6 right-6 z-40 w-80 bg-white rounded-lg shadow-xl border border-gray-200 overflow-hidden transform transition-all duration-300 ease-out ${
isVisible ? "translate-y-0 opacity-100 scale-100" : "translate-y-4 opacity-0 scale-95"
}`}
>
{/* Progress bar at top showing time remaining */}
<div className="h-1 bg-gray-100 w-full">
<div
className="h-full bg-blue-500 transition-all duration-100 ease-linear"
style={{ width: `${progress}%` }}
/>
</div>
<div className="p-4">
<div className="flex items-start justify-between mb-2">
<div className="flex items-center gap-2 text-blue-600">
<MessageSquare className="h-5 w-5" />
<span className="font-semibold text-sm">Quick feedback</span>
</div>
<button
onClick={onDismiss}
className="text-gray-400 hover:text-gray-600 transition-colors p-0.5 rounded hover:bg-gray-100"
>
<X className="h-4 w-4" />
</button>
</div>
<p className="text-sm text-gray-600 mb-3">
Help us improve LiteLLM! Share your experience in 5 quick questions.
</p>
<Button type="primary" block onClick={onOpen}>
Share feedback
</Button>
</div>
</div>
<NudgePrompt
onOpen={onOpen}
onDismiss={onDismiss}
isVisible={isVisible}
title="Quick feedback"
description="Help us improve LiteLLM! Share your experience in 5 quick questions."
buttonText="Share feedback"
icon={MessageSquare}
accentColor="#3b82f6"
/>
);
}
@@ -1,3 +1,6 @@
export { SurveyPrompt } from "./SurveyPrompt";
export { SurveyModal } from "./SurveyModal";
export { ClaudeCodePrompt } from "./ClaudeCodePrompt";
export { ClaudeCodeModal } from "./ClaudeCodeModal";
export { NudgePrompt } from "./NudgePrompt";