mirror of
https://github.com/tiennm99/litellm.git
synced 2026-07-03 15:21:18 +00:00
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:
@@ -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"
|
||||
]
|
||||
}]
|
||||
-2
@@ -1,2 +0,0 @@
|
||||
-- This is an empty migration.
|
||||
|
||||
-2
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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";
|
||||
|
||||
|
||||
Reference in New Issue
Block a user