From 9e1ec7f5626040abcbc1b74dffd6cac51826a5dc Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 15 Nov 2025 14:32:49 -0800 Subject: [PATCH 1/3] feat(billing): add notif for first failed payment, added upgrade email from free, updated providers that supported granular tool control to support them, fixed envvar popover, fixed redirect to wrong workspace after oauth connect --- .../components/tool-input/tool-input.tsx | 10 +- .../components/account/account.tsx | 2 +- .../creator-profile/creator-profile.tsx | 2 +- .../components/credentials/credentials.tsx | 43 +--- .../settings-modal/components/files/files.tsx | 2 +- .../components/general/general.tsx | 2 +- .../components/privacy/privacy.tsx | 2 +- .../components/subscription/subscription.tsx | 20 +- .../usage-indicator/usage-indicator.tsx | 54 +++- .../enterprise-subscription-email.tsx | 4 +- .../billing/free-tier-upgrade-email.tsx | 142 +++++++++++ .../emails/billing/payment-failed-email.tsx | 167 +++++++++++++ .../{ => billing}/plan-welcome-email.tsx | 2 +- .../{ => billing}/usage-threshold-email.tsx | 2 +- .../careers-confirmation-email.tsx | 4 +- .../careers-submission-email.tsx | 2 +- apps/sim/components/emails/index.ts | 6 +- apps/sim/components/emails/render-email.ts | 23 ++ apps/sim/hooks/queries/creator-profile.ts | 4 +- apps/sim/hooks/queries/oauth-connections.ts | 2 +- apps/sim/hooks/queries/user-profile.ts | 2 +- apps/sim/hooks/queries/workspace-files.ts | 2 +- apps/sim/lib/billing/core/usage.ts | 153 ++++++++---- apps/sim/lib/billing/webhooks/invoices.ts | 231 +++++++++++++++++- apps/sim/providers/cerebras/index.ts | 63 +++-- apps/sim/providers/cerebras/types.ts | 8 +- apps/sim/providers/groq/index.ts | 58 +++-- apps/sim/providers/models.ts | 7 +- apps/sim/providers/ollama/index.ts | 96 +++----- apps/sim/providers/utils.test.ts | 2 +- apps/sim/providers/utils.ts | 3 +- 31 files changed, 886 insertions(+), 234 deletions(-) rename apps/sim/components/emails/{ => billing}/enterprise-subscription-email.tsx (96%) create mode 100644 apps/sim/components/emails/billing/free-tier-upgrade-email.tsx create mode 100644 apps/sim/components/emails/billing/payment-failed-email.tsx rename apps/sim/components/emails/{ => billing}/plan-welcome-email.tsx (98%) rename apps/sim/components/emails/{ => billing}/usage-threshold-email.tsx (98%) rename apps/sim/components/emails/{ => careers}/careers-confirmation-email.tsx (96%) rename apps/sim/components/emails/{ => careers}/careers-submission-email.tsx (99%) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx index ffd21cfcb2d..845112ad3e2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel-new/components/editor/components/sub-block/components/tool-input/tool-input.tsx @@ -1642,19 +1642,19 @@ export function ToolInput({

{tool.usageControl === 'auto' && ( - Auto: The model decides when to - use the tool + The model decides when to use the + tool )} {tool.usageControl === 'force' && ( - Force: Always use this tool in - the response + Always use this tool in the + response )} {tool.usageControl === 'none' && ( - Deny: Never use this tool + Never use this tool )}

diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx index 8faf32af0fb..37ec50e7a3b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/account/account.tsx @@ -26,7 +26,7 @@ export function Account(_props: AccountProps) { const router = useRouter() const brandConfig = useBrandConfig() - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: profile } = useUserProfile() const updateProfile = useUpdateUserProfile() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx index 238c278f7b8..fbc61919b15 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/creator-profile/creator-profile.tsx @@ -41,7 +41,7 @@ export function CreatorProfile() { const { data: session } = useSession() const userId = session?.user?.id || '' - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: organizations = [] } = useOrganizations() const { data: existingProfile } = useCreatorProfile(userId) const saveProfile = useSaveCreatorProfile() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx index 2169c78e570..25c4c61df47 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/credentials/credentials.tsx @@ -5,7 +5,6 @@ import { Check, ChevronDown, ExternalLink, Search } from 'lucide-react' import { useRouter, useSearchParams } from 'next/navigation' import { Button } from '@/components/emcn' import { Input, Label } from '@/components/ui' -import { useSession } from '@/lib/auth-client' import { createLogger } from '@/lib/logs/console/logger' import { OAUTH_PROVIDERS } from '@/lib/oauth/oauth' import { cn } from '@/lib/utils' @@ -26,11 +25,9 @@ interface CredentialsProps { export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsProps) { const router = useRouter() const searchParams = useSearchParams() - const { data: session } = useSession() - const userId = session?.user?.id const pendingServiceRef = useRef(null) - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: services = [] } = useOAuthConnections() const connectService = useConnectOAuthService() const disconnectService = useDisconnectOAuthService() @@ -38,51 +35,28 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP // Local UI state const [searchTerm, setSearchTerm] = useState('') const [pendingService, setPendingService] = useState(null) - const [_pendingScopes, setPendingScopes] = useState([]) const [authSuccess, setAuthSuccess] = useState(false) const [showActionRequired, setShowActionRequired] = useState(false) const prevConnectedIdsRef = useRef>(new Set()) const connectionAddedRef = useRef(false) - // Check for OAuth callback + // Check for OAuth callback - just show success message useEffect(() => { const code = searchParams.get('code') const state = searchParams.get('state') const error = searchParams.get('error') - // Handle OAuth callback if (code && state) { - // This is an OAuth callback - try to restore state from localStorage - try { - const stored = localStorage.getItem('pending_oauth_state') - if (stored) { - const oauthState = JSON.parse(stored) - logger.info('OAuth callback with restored state:', oauthState) - - // Mark as pending if we have context about what service was being connected - if (oauthState.serviceId) { - setPendingService(oauthState.serviceId) - setShowActionRequired(true) - } - - // Clean up the state (one-time use) - localStorage.removeItem('pending_oauth_state') - } else { - logger.warn('OAuth callback but no state found in localStorage') - } - } catch (error) { - logger.error('Error loading OAuth state from localStorage:', error) - localStorage.removeItem('pending_oauth_state') // Clean up corrupted state - } - - // Set success flag + logger.info('OAuth callback successful') setAuthSuccess(true) - // Clear the URL parameters - router.replace('/workspace') + // Clear URL parameters without changing the page + const url = new URL(window.location.href) + url.searchParams.delete('code') + url.searchParams.delete('state') + router.replace(url.pathname + url.search) } else if (error) { logger.error('OAuth error:', { error }) - router.replace('/workspace') } }, [searchParams, router]) @@ -132,6 +106,7 @@ export function Credentials({ onOpenChange, registerCloseHandler }: CredentialsP scopes: service.scopes, }) + // better-auth will automatically redirect back to this URL after OAuth await connectService.mutateAsync({ providerId: service.providerId, callbackURL: window.location.href, diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx index 0bf3ceb3dab..0e79ebf3922 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/files/files.tsx @@ -55,7 +55,7 @@ export function Files() { const params = useParams() const workspaceId = params?.workspaceId as string - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: files = [] } = useWorkspaceFiles(workspaceId) const { data: storageInfo } = useStorageInfo(isBillingEnabled) const uploadFile = useUploadWorkspaceFile() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx index ba4df7e57c3..e4b5c9ed093 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/general/general.tsx @@ -34,7 +34,7 @@ export function General() { const [isSuperUser, setIsSuperUser] = useState(false) const [loadingSuperUser, setLoadingSuperUser] = useState(true) - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: settings, isLoading } = useGeneralSettings() const updateSetting = useUpdateGeneralSetting() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx index 2f7e7f37215..003c6837954 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/privacy/privacy.tsx @@ -13,7 +13,7 @@ const TOOLTIPS = { } export function Privacy() { - // React Query hooks - with placeholderData to show cached data immediately (no skeleton loading!) + // React Query hooks - with placeholderData to show cached data immediately const { data: settings } = useGeneralSettings() const updateSetting = useUpdateGeneralSetting() diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx index e7ee7eb38ac..274be344bf4 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/settings-modal/components/subscription/subscription.tsx @@ -469,6 +469,15 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { /> + {/* Enterprise Usage Limit Notice */} + {subscription.isEnterprise && ( +
+

+ Contact enterprise for support usage limit changes +

+
+ )} + {/* Cost Breakdown */} {/* TODO: Re-enable CostBreakdown component in the next billing period once sufficient copilot cost data has been collected for accurate display. @@ -554,14 +563,6 @@ export function Subscription({ onOpenChange }: SubscriptionProps) { {/* Billing usage notifications toggle */} {subscription.isPaid && } - {subscription.isEnterprise && ( -
-

- Contact enterprise for support usage limit changes -

-
- )} - {/* Cancel Subscription */} {permissions.canCancelSubscription && (
@@ -631,9 +632,6 @@ function BillingUsageNotificationsToggle() { const updateSetting = useUpdateGeneralSetting() const isLoading = updateSetting.isPending - // Settings are automatically loaded by SettingsLoader provider - // No need to load here - Zustand is synced from React Query - return (
diff --git a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx index 6ecd1e385dc..34dfacee8d8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/components/sidebar/components-new/usage-indicator/usage-indicator.tsx @@ -184,7 +184,7 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { ) } - const handleClick = () => { + const handleClick = async () => { try { if (onClick) { onClick() @@ -194,7 +194,35 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { const blocked = getBillingStatus(subscriptionData?.data) === 'blocked' const canUpg = canUpgrade(subscriptionData?.data) - // Open Settings modal to the subscription tab (upgrade UI lives there) + // If blocked, try to open billing portal directly for faster recovery + if (blocked) { + try { + const context = subscription.isTeam || subscription.isEnterprise ? 'organization' : 'user' + const organizationId = + subscription.isTeam || subscription.isEnterprise + ? subscriptionData?.data?.organization?.id + : undefined + + const response = await fetch('/api/billing/portal', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ context, organizationId }), + }) + + if (response.ok) { + const { url } = await response.json() + window.open(url, '_blank') + logger.info('Opened billing portal for blocked account', { context, organizationId }) + return + } + } catch (portalError) { + logger.warn('Failed to open billing portal, falling back to settings', { + error: portalError, + }) + } + } + + // Fallback: Open Settings modal to the subscription tab if (typeof window !== 'undefined') { window.dispatchEvent(new CustomEvent('open-settings', { detail: { tab: 'subscription' } })) logger.info('Opened settings to subscription tab', { blocked, canUpgrade: canUpg }) @@ -206,7 +234,9 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { return (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} @@ -219,8 +249,8 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) {
{isBlocked ? ( <> - Over - limit + Payment + Required ) : ( <> @@ -238,10 +268,14 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { {showUpgradeButton && ( )}
@@ -251,7 +285,11 @@ export function UsageIndicator({ onClick }: UsageIndicatorProps) { {Array.from({ length: pillCount }).map((_, i) => { const isFilled = i < filledPillsCount - const baseColor = isFilled ? (isAlmostOut ? '#ef4444' : '#34B5FF') : '#414141' + const baseColor = isFilled + ? isBlocked || isAlmostOut + ? '#ef4444' + : '#34B5FF' + : '#414141' let backgroundColor = baseColor let backgroundImage: string | undefined diff --git a/apps/sim/components/emails/enterprise-subscription-email.tsx b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx similarity index 96% rename from apps/sim/components/emails/enterprise-subscription-email.tsx rename to apps/sim/components/emails/billing/enterprise-subscription-email.tsx index 1979682b82f..dc09fe20dc5 100644 --- a/apps/sim/components/emails/enterprise-subscription-email.tsx +++ b/apps/sim/components/emails/billing/enterprise-subscription-email.tsx @@ -12,10 +12,10 @@ import { Text, } from '@react-email/components' import { format } from 'date-fns' +import { baseStyles } from '@/components/emails/base-styles' +import EmailFooter from '@/components/emails/footer' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' -import { baseStyles } from './base-styles' -import EmailFooter from './footer' interface EnterpriseSubscriptionEmailProps { userName?: string diff --git a/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx new file mode 100644 index 00000000000..592f4a13239 --- /dev/null +++ b/apps/sim/components/emails/billing/free-tier-upgrade-email.tsx @@ -0,0 +1,142 @@ +import { + Body, + Column, + Container, + Head, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from '@react-email/components' +import { baseStyles } from '@/components/emails/base-styles' +import EmailFooter from '@/components/emails/footer' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/urls/utils' + +interface FreeTierUpgradeEmailProps { + userName?: string + percentUsed: number + currentUsage: number + limit: number + upgradeLink: string + updatedDate?: Date +} + +export function FreeTierUpgradeEmail({ + userName, + percentUsed, + currentUsage, + limit, + upgradeLink, + updatedDate = new Date(), +}: FreeTierUpgradeEmailProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + const previewText = `${brand.name}: You've used ${percentUsed}% of your free credits` + + return ( + + + {previewText} + + +
+ + + {brand.name} + + +
+ +
+ + + + + +
+ +
+ + {userName ? `Hi ${userName},` : 'Hi,'} + + + + You've used ${currentUsage.toFixed(2)} of your{' '} + ${limit.toFixed(2)} free credits ({percentUsed}%). + + + + To ensure uninterrupted service and unlock the full power of {brand.name}, upgrade to + Pro today. + + +
+ + What you get with Pro: + + + • $20/month in credits – 2x your free tier +
Priority support – Get help when you need it +
Advanced features – Access to premium blocks and + integrations +
No interruptions – Never worry about running out of credits +
+
+ +
+ + Upgrade now to keep building without limits. + + + Upgrade to Pro + + + + Questions? We're here to help. +
+
+ Best regards, +
+ The {brand.name} Team +
+ + + Sent on {updatedDate.toLocaleDateString()} • This is a one-time notification at 90%. + +
+
+ + + + + ) +} + +export default FreeTierUpgradeEmail diff --git a/apps/sim/components/emails/billing/payment-failed-email.tsx b/apps/sim/components/emails/billing/payment-failed-email.tsx new file mode 100644 index 00000000000..1d7f41810fd --- /dev/null +++ b/apps/sim/components/emails/billing/payment-failed-email.tsx @@ -0,0 +1,167 @@ +import { + Body, + Column, + Container, + Head, + Hr, + Html, + Img, + Link, + Preview, + Row, + Section, + Text, +} from '@react-email/components' +import { baseStyles } from '@/components/emails/base-styles' +import EmailFooter from '@/components/emails/footer' +import { getBrandConfig } from '@/lib/branding/branding' +import { getBaseUrl } from '@/lib/urls/utils' + +interface PaymentFailedEmailProps { + userName?: string + amountDue: number + lastFourDigits?: string + billingPortalUrl: string + failureReason?: string + sentDate?: Date +} + +export function PaymentFailedEmail({ + userName, + amountDue, + lastFourDigits, + billingPortalUrl, + failureReason, + sentDate = new Date(), +}: PaymentFailedEmailProps) { + const brand = getBrandConfig() + const baseUrl = getBaseUrl() + + const previewText = `${brand.name}: Payment Failed - Action Required` + + return ( + + + {previewText} + + +
+ + + {brand.name} + + +
+ +
+ + + + + +
+ +
+ + {userName ? `Hi ${userName},` : 'Hi,'} + + + + We were unable to process your payment. + + + + Your {brand.name} account has been temporarily blocked to prevent service + interruptions and unexpected charges. To restore access immediately, please update + your payment method. + + +
+ + + + Payment Details + + + Amount due: ${amountDue.toFixed(2)} + + {lastFourDigits && ( + + Payment method: •••• {lastFourDigits} + + )} + {failureReason && ( + + Reason: {failureReason} + + )} + + +
+ + + Update Payment Method + + +
+ + + What happens next? + + + + • Your workflows and automations are currently paused +
• Update your payment method to restore service immediately +
• Stripe will automatically retry the charge once payment is updated +
+ +
+ + + Need help? + + + + Common reasons for payment failures include expired cards, insufficient funds, or + incorrect billing information. If you continue to experience issues, please{' '} + + contact our support team + + . + + + + Best regards, +
+ The Sim Team +
+ + + Sent on {sentDate.toLocaleDateString()} • This is a critical transactional + notification. + +
+
+ + + + + ) +} + +export default PaymentFailedEmail diff --git a/apps/sim/components/emails/plan-welcome-email.tsx b/apps/sim/components/emails/billing/plan-welcome-email.tsx similarity index 98% rename from apps/sim/components/emails/plan-welcome-email.tsx rename to apps/sim/components/emails/billing/plan-welcome-email.tsx index 25bd1a9fac4..ca3745cbfbe 100644 --- a/apps/sim/components/emails/plan-welcome-email.tsx +++ b/apps/sim/components/emails/billing/plan-welcome-email.tsx @@ -12,10 +12,10 @@ import { Section, Text, } from '@react-email/components' +import { baseStyles } from '@/components/emails/base-styles' import EmailFooter from '@/components/emails/footer' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' -import { baseStyles } from './base-styles' interface PlanWelcomeEmailProps { planName: 'Pro' | 'Team' diff --git a/apps/sim/components/emails/usage-threshold-email.tsx b/apps/sim/components/emails/billing/usage-threshold-email.tsx similarity index 98% rename from apps/sim/components/emails/usage-threshold-email.tsx rename to apps/sim/components/emails/billing/usage-threshold-email.tsx index 96e46f69af3..d1b9f3b1223 100644 --- a/apps/sim/components/emails/usage-threshold-email.tsx +++ b/apps/sim/components/emails/billing/usage-threshold-email.tsx @@ -12,10 +12,10 @@ import { Section, Text, } from '@react-email/components' +import { baseStyles } from '@/components/emails/base-styles' import EmailFooter from '@/components/emails/footer' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' -import { baseStyles } from './base-styles' interface UsageThresholdEmailProps { userName?: string diff --git a/apps/sim/components/emails/careers-confirmation-email.tsx b/apps/sim/components/emails/careers/careers-confirmation-email.tsx similarity index 96% rename from apps/sim/components/emails/careers-confirmation-email.tsx rename to apps/sim/components/emails/careers/careers-confirmation-email.tsx index bd931d669f9..0577686da6e 100644 --- a/apps/sim/components/emails/careers-confirmation-email.tsx +++ b/apps/sim/components/emails/careers/careers-confirmation-email.tsx @@ -11,10 +11,10 @@ import { Text, } from '@react-email/components' import { format } from 'date-fns' +import { baseStyles } from '@/components/emails/base-styles' +import EmailFooter from '@/components/emails/footer' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' -import { baseStyles } from './base-styles' -import EmailFooter from './footer' interface CareersConfirmationEmailProps { name: string diff --git a/apps/sim/components/emails/careers-submission-email.tsx b/apps/sim/components/emails/careers/careers-submission-email.tsx similarity index 99% rename from apps/sim/components/emails/careers-submission-email.tsx rename to apps/sim/components/emails/careers/careers-submission-email.tsx index 96246efbcdb..5d3e79d89b9 100644 --- a/apps/sim/components/emails/careers-submission-email.tsx +++ b/apps/sim/components/emails/careers/careers-submission-email.tsx @@ -11,9 +11,9 @@ import { Text, } from '@react-email/components' import { format } from 'date-fns' +import { baseStyles } from '@/components/emails/base-styles' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' -import { baseStyles } from './base-styles' interface CareersSubmissionEmailProps { name: string diff --git a/apps/sim/components/emails/index.ts b/apps/sim/components/emails/index.ts index 800dc923804..d2d7d70d0d9 100644 --- a/apps/sim/components/emails/index.ts +++ b/apps/sim/components/emails/index.ts @@ -1,12 +1,12 @@ export * from './base-styles' export { BatchInvitationEmail } from './batch-invitation-email' -export { EnterpriseSubscriptionEmail } from './enterprise-subscription-email' +export { EnterpriseSubscriptionEmail } from './billing/enterprise-subscription-email' +export { PlanWelcomeEmail } from './billing/plan-welcome-email' +export { UsageThresholdEmail } from './billing/usage-threshold-email' export { default as EmailFooter } from './footer' export { HelpConfirmationEmail } from './help-confirmation-email' export { InvitationEmail } from './invitation-email' export { OTPVerificationEmail } from './otp-verification-email' -export { PlanWelcomeEmail } from './plan-welcome-email' export * from './render-email' export { ResetPasswordEmail } from './reset-password-email' -export { UsageThresholdEmail } from './usage-threshold-email' export { WorkspaceInvitationEmail } from './workspace-invitation' diff --git a/apps/sim/components/emails/render-email.ts b/apps/sim/components/emails/render-email.ts index 8c439130cb3..313a74f145c 100644 --- a/apps/sim/components/emails/render-email.ts +++ b/apps/sim/components/emails/render-email.ts @@ -9,6 +9,7 @@ import { ResetPasswordEmail, UsageThresholdEmail, } from '@/components/emails' +import FreeTierUpgradeEmail from '@/components/emails/billing/free-tier-upgrade-email' import { getBrandConfig } from '@/lib/branding/branding' import { getBaseUrl } from '@/lib/urls/utils' @@ -124,6 +125,25 @@ export async function renderUsageThresholdEmail(params: { ) } +export async function renderFreeTierUpgradeEmail(params: { + userName?: string + percentUsed: number + currentUsage: number + limit: number + upgradeLink: string +}): Promise { + return await render( + FreeTierUpgradeEmail({ + userName: params.userName, + percentUsed: params.percentUsed, + currentUsage: params.currentUsage, + limit: params.limit, + upgradeLink: params.upgradeLink, + updatedDate: new Date(), + }) + ) +} + export function getEmailSubject( type: | 'sign-in' @@ -135,6 +155,7 @@ export function getEmailSubject( | 'help-confirmation' | 'enterprise-subscription' | 'usage-threshold' + | 'free-tier-upgrade' | 'plan-welcome-pro' | 'plan-welcome-team' ): string { @@ -159,6 +180,8 @@ export function getEmailSubject( return `Your Enterprise Plan is now active on ${brandName}` case 'usage-threshold': return `You're nearing your monthly budget on ${brandName}` + case 'free-tier-upgrade': + return `You're at 90% of your free credits on ${brandName}` case 'plan-welcome-pro': return `Your Pro plan is now active on ${brandName}` case 'plan-welcome-team': diff --git a/apps/sim/hooks/queries/creator-profile.ts b/apps/sim/hooks/queries/creator-profile.ts index cf18244d5f1..fee8b81773a 100644 --- a/apps/sim/hooks/queries/creator-profile.ts +++ b/apps/sim/hooks/queries/creator-profile.ts @@ -59,7 +59,7 @@ export function useOrganizations() { queryKey: creatorProfileKeys.organizations(), queryFn: fetchOrganizations, staleTime: 5 * 60 * 1000, // 5 minutes - organizations don't change often - placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!) + placeholderData: keepPreviousData, // Show cached data immediately }) } @@ -97,7 +97,7 @@ export function useCreatorProfile(userId: string) { enabled: !!userId, retry: false, // Don't retry on 404 staleTime: 60 * 1000, // 1 minute - placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!) + placeholderData: keepPreviousData, // Show cached data immediately }) } diff --git a/apps/sim/hooks/queries/oauth-connections.ts b/apps/sim/hooks/queries/oauth-connections.ts index 106cdf6609d..74875539142 100644 --- a/apps/sim/hooks/queries/oauth-connections.ts +++ b/apps/sim/hooks/queries/oauth-connections.ts @@ -123,7 +123,7 @@ export function useOAuthConnections() { queryFn: fetchOAuthConnections, staleTime: 30 * 1000, // 30 seconds - connections don't change often retry: false, // Don't retry on 404 - placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!) + placeholderData: keepPreviousData, // Show cached data immediately }) } diff --git a/apps/sim/hooks/queries/user-profile.ts b/apps/sim/hooks/queries/user-profile.ts index ed18f442a93..f07ad3552c5 100644 --- a/apps/sim/hooks/queries/user-profile.ts +++ b/apps/sim/hooks/queries/user-profile.ts @@ -53,7 +53,7 @@ export function useUserProfile() { queryKey: userProfileKeys.profile(), queryFn: fetchUserProfile, staleTime: 5 * 60 * 1000, // 5 minutes - profile data doesn't change often - placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!) + placeholderData: keepPreviousData, // Show cached data immediately }) } diff --git a/apps/sim/hooks/queries/workspace-files.ts b/apps/sim/hooks/queries/workspace-files.ts index f0748623a45..749be28a20d 100644 --- a/apps/sim/hooks/queries/workspace-files.ts +++ b/apps/sim/hooks/queries/workspace-files.ts @@ -49,7 +49,7 @@ export function useWorkspaceFiles(workspaceId: string) { queryFn: () => fetchWorkspaceFiles(workspaceId), enabled: !!workspaceId, staleTime: 30 * 1000, // 30 seconds - files can change frequently - placeholderData: keepPreviousData, // Show cached data immediately (no skeleton loading!) + placeholderData: keepPreviousData, // Show cached data immediately }) } diff --git a/apps/sim/lib/billing/core/usage.ts b/apps/sim/lib/billing/core/usage.ts index 8d219a8b9e9..2a0eab57f49 100644 --- a/apps/sim/lib/billing/core/usage.ts +++ b/apps/sim/lib/billing/core/usage.ts @@ -1,7 +1,11 @@ import { db } from '@sim/db' import { member, organization, settings, user, userStats } from '@sim/db/schema' import { eq, inArray } from 'drizzle-orm' -import { getEmailSubject, renderUsageThresholdEmail } from '@/components/emails/render-email' +import { + getEmailSubject, + renderFreeTierUpgradeEmail, + renderUsageThresholdEmail, +} from '@/components/emails/render-email' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' import { canEditUsageLimit, @@ -614,60 +618,113 @@ export async function maybeSendUsageThresholdEmail(params: { }): Promise { try { if (!isBillingEnabled) return - // Only on upward crossing to >= 80% - if (!(params.percentBefore < 80 && params.percentAfter >= 80)) return if (params.limit <= 0 || params.currentUsageAfter <= 0) return const baseUrl = getBaseUrl() - const ctaLink = `${baseUrl}/workspace?billing=usage` - const sendTo = async (email: string, name?: string) => { - const prefs = await getEmailPreferences(email) - if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return - - const html = await renderUsageThresholdEmail({ - userName: name, - planName: params.planName, - percentUsed: Math.min(100, Math.round(params.percentAfter)), - currentUsage: params.currentUsageAfter, - limit: params.limit, - ctaLink, - }) + const isFreeUser = params.planName === 'Free' + + // Check for 80% threshold (all users) + const crosses80 = params.percentBefore < 80 && params.percentAfter >= 80 + // Check for 90% threshold (free users only) + const crosses90 = params.percentBefore < 90 && params.percentAfter >= 90 + + // Skip if no thresholds crossed + if (!crosses80 && !crosses90) return + + // For 80% threshold email (all users) + if (crosses80) { + const ctaLink = `${baseUrl}/workspace?billing=usage` + const sendTo = async (email: string, name?: string) => { + const prefs = await getEmailPreferences(email) + if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return + + const html = await renderUsageThresholdEmail({ + userName: name, + planName: params.planName, + percentUsed: Math.min(100, Math.round(params.percentAfter)), + currentUsage: params.currentUsageAfter, + limit: params.limit, + ctaLink, + }) - await sendEmail({ - to: email, - subject: getEmailSubject('usage-threshold'), - html, - emailType: 'notifications', - }) + await sendEmail({ + to: email, + subject: getEmailSubject('usage-threshold'), + html, + emailType: 'notifications', + }) + } + + if (params.scope === 'user' && params.userId && params.userEmail) { + const rows = await db + .select({ enabled: settings.billingUsageNotificationsEnabled }) + .from(settings) + .where(eq(settings.userId, params.userId)) + .limit(1) + if (rows.length > 0 && rows[0].enabled === false) return + await sendTo(params.userEmail, params.userName) + } else if (params.scope === 'organization' && params.organizationId) { + const admins = await db + .select({ + email: user.email, + name: user.name, + enabled: settings.billingUsageNotificationsEnabled, + role: member.role, + }) + .from(member) + .innerJoin(user, eq(member.userId, user.id)) + .leftJoin(settings, eq(settings.userId, member.userId)) + .where(eq(member.organizationId, params.organizationId)) + + for (const a of admins) { + const isAdmin = a.role === 'owner' || a.role === 'admin' + if (!isAdmin) continue + if (a.enabled === false) continue + if (!a.email) continue + await sendTo(a.email, a.name || undefined) + } + } } - if (params.scope === 'user' && params.userId && params.userEmail) { - const rows = await db - .select({ enabled: settings.billingUsageNotificationsEnabled }) - .from(settings) - .where(eq(settings.userId, params.userId)) - .limit(1) - if (rows.length > 0 && rows[0].enabled === false) return - await sendTo(params.userEmail, params.userName) - } else if (params.scope === 'organization' && params.organizationId) { - const admins = await db - .select({ - email: user.email, - name: user.name, - enabled: settings.billingUsageNotificationsEnabled, - role: member.role, + // For 90% threshold email (free users only) + if (crosses90 && isFreeUser) { + const upgradeLink = `${baseUrl}/workspace?billing=upgrade` + const sendFreeTierEmail = async (email: string, name?: string) => { + const prefs = await getEmailPreferences(email) + if (prefs?.unsubscribeAll || prefs?.unsubscribeNotifications) return + + const html = await renderFreeTierUpgradeEmail({ + userName: name, + percentUsed: Math.min(100, Math.round(params.percentAfter)), + currentUsage: params.currentUsageAfter, + limit: params.limit, + upgradeLink, + }) + + await sendEmail({ + to: email, + subject: getEmailSubject('free-tier-upgrade'), + html, + emailType: 'notifications', }) - .from(member) - .innerJoin(user, eq(member.userId, user.id)) - .leftJoin(settings, eq(settings.userId, member.userId)) - .where(eq(member.organizationId, params.organizationId)) - - for (const a of admins) { - const isAdmin = a.role === 'owner' || a.role === 'admin' - if (!isAdmin) continue - if (a.enabled === false) continue - if (!a.email) continue - await sendTo(a.email, a.name || undefined) + + logger.info('Free tier upgrade email sent', { + email, + percentUsed: Math.round(params.percentAfter), + currentUsage: params.currentUsageAfter, + limit: params.limit, + }) + } + + // Free users are always individual scope (not organization) + if (params.scope === 'user' && params.userId && params.userEmail) { + const rows = await db + .select({ enabled: settings.billingUsageNotificationsEnabled }) + .from(settings) + .where(eq(settings.userId, params.userId)) + .limit(1) + if (rows.length > 0 && rows[0].enabled === false) return + await sendFreeTierEmail(params.userEmail, params.userName) } } } catch (error) { diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index a2d6d08e93e..c6af3f9c33c 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,10 +1,14 @@ +import { render } from '@react-email/components' import { db } from '@sim/db' -import { member, subscription as subscriptionTable, userStats } from '@sim/db/schema' +import { member, subscription as subscriptionTable, user, userStats } from '@sim/db/schema' import { eq, inArray } from 'drizzle-orm' import type Stripe from 'stripe' +import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email' import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { requireStripeClient } from '@/lib/billing/stripe-client' +import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' +import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('StripeInvoiceWebhooks') @@ -19,6 +23,201 @@ function parseDecimal(value: string | number | null | undefined): number { return Number.parseFloat(value.toString()) } +/** + * Create a billing portal URL for a Stripe customer + */ +async function createBillingPortalUrl(stripeCustomerId: string): Promise { + try { + const stripe = requireStripeClient() + const baseUrl = getBaseUrl() + const portal = await stripe.billingPortal.sessions.create({ + customer: stripeCustomerId, + return_url: `${baseUrl}/workspace?billing=updated`, + }) + return portal.url + } catch (error) { + logger.error('Failed to create billing portal URL', { error, stripeCustomerId }) + // Fallback to generic billing page + return `${getBaseUrl()}/workspace?tab=subscription` + } +} + +/** + * Get payment method details from Stripe invoice + */ +async function getPaymentMethodDetails( + invoice: Stripe.Invoice +): Promise<{ lastFourDigits?: string; failureReason?: string }> { + let lastFourDigits: string | undefined + let failureReason: string | undefined + + // Try to get last 4 digits from payment method + try { + const stripe = requireStripeClient() + + // Try to get from default payment method + if (invoice.default_payment_method && typeof invoice.default_payment_method === 'string') { + const paymentMethod = await stripe.paymentMethods.retrieve(invoice.default_payment_method) + if (paymentMethod.card?.last4) { + lastFourDigits = paymentMethod.card.last4 + } + } + + // If no default payment method, try getting from customer's default + if (!lastFourDigits && invoice.customer && typeof invoice.customer === 'string') { + const customer = await stripe.customers.retrieve(invoice.customer) + if (customer && !('deleted' in customer)) { + const defaultPm = customer.invoice_settings?.default_payment_method + if (defaultPm && typeof defaultPm === 'string') { + const paymentMethod = await stripe.paymentMethods.retrieve(defaultPm) + if (paymentMethod.card?.last4) { + lastFourDigits = paymentMethod.card.last4 + } + } + } + } + } catch (error) { + logger.warn('Failed to retrieve payment method details', { error, invoiceId: invoice.id }) + } + + // Get failure message - check multiple sources + if (invoice.last_finalization_error?.message) { + failureReason = invoice.last_finalization_error.message + } + + // If not found, check the payments array (requires expand: ['payments']) + if (!failureReason && invoice.payments?.data) { + const defaultPayment = invoice.payments.data.find((p) => p.is_default) + const payment = defaultPayment || invoice.payments.data[0] + + if (payment?.payment) { + try { + const stripe = requireStripeClient() + + if (payment.payment.type === 'payment_intent' && payment.payment.payment_intent) { + const piId = + typeof payment.payment.payment_intent === 'string' + ? payment.payment.payment_intent + : payment.payment.payment_intent.id + + const paymentIntent = await stripe.paymentIntents.retrieve(piId) + if (paymentIntent.last_payment_error?.message) { + failureReason = paymentIntent.last_payment_error.message + } + } else if (payment.payment.type === 'charge' && payment.payment.charge) { + const chargeId = + typeof payment.payment.charge === 'string' + ? payment.payment.charge + : payment.payment.charge.id + + const charge = await stripe.charges.retrieve(chargeId) + if (charge.failure_message) { + failureReason = charge.failure_message + } + } + } catch (error) { + logger.warn('Failed to retrieve payment details for failure reason', { + error, + invoiceId: invoice.id, + }) + } + } + } + + return { lastFourDigits, failureReason } +} + +/** + * Send payment failure notification emails to affected users + */ +async function sendPaymentFailureEmails( + sub: { plan: string | null; referenceId: string }, + invoice: Stripe.Invoice, + stripeCustomerId: string +): Promise { + try { + const billingPortalUrl = await createBillingPortalUrl(stripeCustomerId) + const amountDue = invoice.amount_due / 100 // Convert cents to dollars + const { lastFourDigits, failureReason } = await getPaymentMethodDetails(invoice) + + // Get users to notify + let usersToNotify: Array<{ email: string; name: string | null }> = [] + + if (sub.plan === 'team' || sub.plan === 'enterprise') { + // For team/enterprise, notify all owners and admins + const members = await db + .select({ + userId: member.userId, + role: member.role, + }) + .from(member) + .where(eq(member.organizationId, sub.referenceId)) + + // Get owner/admin user details + const ownerAdminIds = members + .filter((m) => m.role === 'owner' || m.role === 'admin') + .map((m) => m.userId) + + if (ownerAdminIds.length > 0) { + const users = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(inArray(user.id, ownerAdminIds)) + + // Filter out invalid email addresses + usersToNotify = users.filter((u) => u.email?.includes('@') && u.email.length > 3) + } + } else { + // For individual plans, notify the user + const users = await db + .select({ email: user.email, name: user.name }) + .from(user) + .where(eq(user.id, sub.referenceId)) + .limit(1) + + if (users.length > 0) { + // Filter out invalid email addresses + usersToNotify = users.filter((u) => u.email?.includes('@') && u.email.length > 3) + } + } + + // Send emails to all affected users + for (const userToNotify of usersToNotify) { + try { + const emailHtml = await render( + PaymentFailedEmail({ + userName: userToNotify.name || undefined, + amountDue, + lastFourDigits, + billingPortalUrl, + failureReason, + sentDate: new Date(), + }) + ) + + await sendEmail({ + to: userToNotify.email, + subject: 'Payment Failed - Action Required', + html: emailHtml, + emailType: 'transactional', + }) + + logger.info('Payment failure email sent', { + email: userToNotify.email, + invoiceId: invoice.id, + }) + } catch (emailError) { + logger.error('Failed to send payment failure email', { + error: emailError, + email: userToNotify.email, + }) + } + } + } catch (error) { + logger.error('Failed to send payment failure emails', { error }) + } +} + /** * Get total billed overage for a subscription, handling team vs individual plans * For team plans: sums billedOverageThisPeriod across all members @@ -237,10 +436,19 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { return } - const customerId = invoice.customer as string + // Extract and validate customer ID + const customerId = invoice.customer + if (!customerId || typeof customerId !== 'string') { + logger.error('Invalid customer ID on invoice', { + invoiceId: invoice.id, + customer: invoice.customer, + }) + return + } + const failedAmount = invoice.amount_due / 100 // Convert from cents to dollars const billingPeriod = invoice.metadata?.billingPeriod || 'unknown' - const attemptCount = invoice.attempt_count || 1 + const attemptCount = invoice.attempt_count ?? 1 logger.warn('Invoice payment failed', { invoiceId: invoice.id, @@ -300,6 +508,23 @@ export async function handleInvoicePaymentFailed(event: Stripe.Event) { isOverageInvoice, }) } + + // Send payment failure notification emails + // Only send on FIRST failure (attempt_count === 1), not on Stripe's automatic retries + // This prevents spamming users with duplicate emails every 3-5-7 days + if (attemptCount === 1) { + await sendPaymentFailureEmails(sub, invoice, customerId) + logger.info('Payment failure email sent on first attempt', { + invoiceId: invoice.id, + customerId, + }) + } else { + logger.info('Skipping payment failure email on retry attempt', { + invoiceId: invoice.id, + attemptCount, + customerId, + }) + } } else { logger.warn('Subscription not found in database for failed payment', { stripeSubscriptionId, diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 717d0babc16..247c111cba6 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -8,7 +8,11 @@ import type { ProviderResponse, TimeSegment, } from '@/providers/types' -import { prepareToolExecution } from '@/providers/utils' +import { + prepareToolExecution, + prepareToolsWithUsageControl, + trackForcedToolUsage, +} from '@/providers/utils' import { executeTool } from '@/tools' const logger = createLogger('CerebrasProvider') @@ -116,29 +120,29 @@ export const cerebrasProvider: ProviderConfig = { } } - // Add tools if provided + // Handle tools and tool usage control + // Cerebras supports full OpenAI-compatible tool_choice including forcing specific tools + let originalToolChoice: any + let forcedTools: string[] = [] + let hasFilteredTools = false + if (tools?.length) { - // Filter out any tools with usageControl='none', treat 'force' as 'auto' since Cerebras only supports 'auto' - const filteredTools = tools.filter((tool) => { - const toolId = tool.function?.name - const toolConfig = request.tools?.find((t) => t.id === toolId) - // Only filter out tools with usageControl='none' - return toolConfig?.usageControl !== 'none' - }) + const preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') - if (filteredTools?.length) { - payload.tools = filteredTools - // Always use 'auto' for Cerebras, explicitly converting any 'force' usageControl to 'auto' - payload.tool_choice = 'auto' + if (preparedTools.tools?.length) { + payload.tools = preparedTools.tools + payload.tool_choice = preparedTools.toolChoice || 'auto' + originalToolChoice = preparedTools.toolChoice + forcedTools = preparedTools.forcedTools || [] + hasFilteredTools = preparedTools.hasFilteredTools logger.info('Cerebras request configuration:', { - toolCount: filteredTools.length, - toolChoice: 'auto', // Cerebras always uses auto, 'force' is treated as 'auto' + toolCount: preparedTools.tools.length, + toolChoice: payload.tool_choice, + forcedToolsCount: forcedTools.length, + hasFilteredTools, model: request.model, }) - } else if (tools.length > 0 && filteredTools.length === 0) { - // Handle case where all tools are filtered out - logger.info(`All tools have usageControl='none', removing tools from request`) } } @@ -357,6 +361,29 @@ export const cerebrasProvider: ProviderConfig = { const thisToolsTime = Date.now() - toolsStartTime toolsTime += thisToolsTime + // Check if we used any forced tools and update tool_choice for the next iteration + let usedForcedTools: string[] = [] + if (typeof originalToolChoice === 'object' && forcedTools.length > 0) { + const toolTracking = trackForcedToolUsage( + currentResponse.choices[0]?.message?.tool_calls, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + usedForcedTools = toolTracking.usedForcedTools + const nextToolChoice = toolTracking.nextToolChoice + + // Update tool_choice for next iteration if we're still forcing tools + if (nextToolChoice && typeof nextToolChoice === 'object') { + payload.tool_choice = nextToolChoice + } else if (nextToolChoice === 'auto' || !nextToolChoice) { + // All forced tools have been used, switch to auto + payload.tool_choice = 'auto' + } + } + // After processing tool calls, get a final response if (processedAnyToolCall || hasRepeatedToolCalls) { // Time the next model call diff --git a/apps/sim/providers/cerebras/types.ts b/apps/sim/providers/cerebras/types.ts index 085687941c0..02683f9fe4e 100644 --- a/apps/sim/providers/cerebras/types.ts +++ b/apps/sim/providers/cerebras/types.ts @@ -1,4 +1,4 @@ -interface CerebrasMessage { +export interface CerebrasMessage { role: string content: string | null tool_calls?: Array<{ @@ -12,19 +12,19 @@ interface CerebrasMessage { tool_call_id?: string } -interface CerebrasChoice { +export interface CerebrasChoice { message: CerebrasMessage index: number finish_reason: string } -interface CerebrasUsage { +export interface CerebrasUsage { prompt_tokens: number completion_tokens: number total_tokens: number } -interface CerebrasResponse { +export interface CerebrasResponse { id: string object: string created: number diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index d9ac569d210..027f5019208 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -8,7 +8,11 @@ import type { ProviderResponse, TimeSegment, } from '@/providers/types' -import { prepareToolExecution } from '@/providers/utils' +import { + prepareToolExecution, + prepareToolsWithUsageControl, + trackForcedToolUsage, +} from '@/providers/utils' import { executeTool } from '@/tools' const logger = createLogger('GroqProvider') @@ -110,23 +114,26 @@ export const groqProvider: ProviderConfig = { } // Handle tools and tool usage control + // Groq supports full OpenAI-compatible tool_choice including forcing specific tools + let originalToolChoice: any + let forcedTools: string[] = [] + let hasFilteredTools = false + if (tools?.length) { - // Filter out any tools with usageControl='none', but ignore 'force' since Groq doesn't support it - const filteredTools = tools.filter((tool) => { - const toolId = tool.function?.name - const toolConfig = request.tools?.find((t) => t.id === toolId) - // Only filter out 'none', treat 'force' as 'auto' - return toolConfig?.usageControl !== 'none' - }) + const preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'openai') - if (filteredTools?.length) { - payload.tools = filteredTools - // Always use 'auto' for Groq, regardless of the tool_choice setting - payload.tool_choice = 'auto' + if (preparedTools.tools?.length) { + payload.tools = preparedTools.tools + payload.tool_choice = preparedTools.toolChoice || 'auto' + originalToolChoice = preparedTools.toolChoice + forcedTools = preparedTools.forcedTools || [] + hasFilteredTools = preparedTools.hasFilteredTools logger.info('Groq request configuration:', { - toolCount: filteredTools.length, - toolChoice: 'auto', // Groq always uses auto + toolCount: preparedTools.tools.length, + toolChoice: payload.tool_choice, + forcedToolsCount: forcedTools.length, + hasFilteredTools, model: request.model || 'groq/meta-llama/llama-4-scout-17b-16e-instruct', }) } @@ -328,6 +335,29 @@ export const groqProvider: ProviderConfig = { const thisToolsTime = Date.now() - toolsStartTime toolsTime += thisToolsTime + // Check if we used any forced tools and update tool_choice for the next iteration + let usedForcedTools: string[] = [] + if (typeof originalToolChoice === 'object' && forcedTools.length > 0) { + const toolTracking = trackForcedToolUsage( + currentResponse.choices[0]?.message?.tool_calls, + originalToolChoice, + logger, + 'openai', + forcedTools, + usedForcedTools + ) + usedForcedTools = toolTracking.usedForcedTools + const nextToolChoice = toolTracking.nextToolChoice + + // Update tool_choice for next iteration if we're still forcing tools + if (nextToolChoice && typeof nextToolChoice === 'object') { + payload.tool_choice = nextToolChoice + } else if (nextToolChoice === 'auto' || !nextToolChoice) { + // All forced tools have been used, switch to auto + payload.tool_choice = 'auto' + } + } + // Make the next request with updated messages const nextPayload = { ...payload, diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index ceeebc70a6c..00579a61efd 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -766,7 +766,7 @@ export const PROVIDER_DEFINITIONS: Record = { modelPatterns: [/^cerebras/], icon: CerebrasIcon, capabilities: { - toolUsageControl: false, + toolUsageControl: true, }, models: [ { @@ -815,7 +815,7 @@ export const PROVIDER_DEFINITIONS: Record = { modelPatterns: [/^groq/], icon: GroqIcon, capabilities: { - toolUsageControl: false, + toolUsageControl: true, }, models: [ { @@ -1093,6 +1093,9 @@ export const PROVIDER_DEFINITIONS: Record = { defaultModel: '', modelPatterns: [], icon: OllamaIcon, + capabilities: { + toolUsageControl: false, // Ollama does not support tool_choice parameter + }, models: [], // Populated dynamically }, } diff --git a/apps/sim/providers/ollama/index.ts b/apps/sim/providers/ollama/index.ts index 35fc219de06..c1f28204622 100644 --- a/apps/sim/providers/ollama/index.ts +++ b/apps/sim/providers/ollama/index.ts @@ -9,11 +9,7 @@ import type { ProviderResponse, TimeSegment, } from '@/providers/types' -import { - prepareToolExecution, - prepareToolsWithUsageControl, - trackForcedToolUsage, -} from '@/providers/utils' +import { prepareToolExecution } from '@/providers/utils' import { useProvidersStore } from '@/stores/providers/store' import { executeTool } from '@/tools' @@ -173,20 +169,42 @@ export const ollamaProvider: ProviderConfig = { } // Handle tools and tool usage control - let preparedTools: ReturnType | null = null - + // NOTE: Ollama does NOT support the tool_choice parameter beyond basic 'auto' behavior + // According to official documentation, tool_choice is silently ignored + // Ollama only supports basic function calling where the model autonomously decides if (tools?.length) { - preparedTools = prepareToolsWithUsageControl(tools, request.tools, logger, 'ollama') - const { tools: filteredTools, toolChoice } = preparedTools + // Filter out tools with usageControl='none' + // Treat 'force' as 'auto' since Ollama doesn't support forced tool selection + const filteredTools = tools.filter((tool) => { + const toolId = tool.function?.name + const toolConfig = request.tools?.find((t) => t.id === toolId) + // Only filter out 'none', treat 'force' as 'auto' + return toolConfig?.usageControl !== 'none' + }) + + // Check if any tools were forcibly marked + const hasForcedTools = tools.some((tool) => { + const toolId = tool.function?.name + const toolConfig = request.tools?.find((t) => t.id === toolId) + return toolConfig?.usageControl === 'force' + }) - if (filteredTools?.length && toolChoice) { + if (hasForcedTools) { + logger.warn( + 'Ollama does not support forced tool selection (tool_choice parameter is ignored). ' + + 'Tools marked with usageControl="force" will behave as "auto" instead.' + ) + } + + if (filteredTools?.length) { payload.tools = filteredTools - // Ollama supports 'auto' but not forced tool selection - convert 'force' to 'auto' - payload.tool_choice = typeof toolChoice === 'string' ? toolChoice : 'auto' + // Ollama only supports 'auto' behavior - model decides whether to use tools + payload.tool_choice = 'auto' logger.info('Ollama request configuration:', { toolCount: filteredTools.length, - toolChoice: payload.tool_choice, + toolChoice: 'auto', // Ollama always uses auto + forcedToolsIgnored: hasForcedTools, model: request.model, }) } @@ -295,33 +313,6 @@ export const ollamaProvider: ProviderConfig = { // Make the initial API request const initialCallTime = Date.now() - // Track the original tool_choice for forced tool tracking - const originalToolChoice = payload.tool_choice - - // Track forced tools and their usage - const forcedTools = preparedTools?.forcedTools || [] - let usedForcedTools: string[] = [] - - // Helper function to check for forced tool usage in responses - const checkForForcedToolUsage = ( - response: any, - toolChoice: string | { type: string; function?: { name: string }; name?: string; any?: any } - ) => { - if (typeof toolChoice === 'object' && response.choices[0]?.message?.tool_calls) { - const toolCallsResponse = response.choices[0].message.tool_calls - const result = trackForcedToolUsage( - toolCallsResponse, - toolChoice, - logger, - 'ollama', - forcedTools, - usedForcedTools - ) - hasUsedForcedTool = result.hasUsedForcedTool - usedForcedTools = result.usedForcedTools - } - } - let currentResponse = await ollama.chat.completions.create(payload) const firstResponseTime = Date.now() - initialCallTime @@ -349,9 +340,6 @@ export const ollamaProvider: ProviderConfig = { let modelTime = firstResponseTime let toolsTime = 0 - // Track if a forced tool has been used - let hasUsedForcedTool = false - // Track each model and tool call segment with timestamps const timeSegments: TimeSegment[] = [ { @@ -363,9 +351,6 @@ export const ollamaProvider: ProviderConfig = { }, ] - // Check if a forced tool was used in the first response - checkForForcedToolUsage(currentResponse, originalToolChoice) - while (iterationCount < MAX_ITERATIONS) { // Check for tool calls const toolCallsInResponse = currentResponse.choices[0]?.message?.tool_calls @@ -470,31 +455,12 @@ export const ollamaProvider: ProviderConfig = { messages: currentMessages, } - // Update tool_choice based on which forced tools have been used - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { - // If we have remaining forced tools, get the next one to force - const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - - if (remainingTools.length > 0) { - // Ollama doesn't support forced tool selection, so we keep using 'auto' - nextPayload.tool_choice = 'auto' - logger.info(`Ollama doesn't support forced tools, using auto for: ${remainingTools[0]}`) - } else { - // All forced tools have been used, continue with auto - nextPayload.tool_choice = 'auto' - logger.info('All forced tools have been used, continuing with auto tool_choice') - } - } - // Time the next model call const nextModelStartTime = Date.now() // Make the next request currentResponse = await ollama.chat.completions.create(nextPayload) - // Check if any forced tools were used in this response - checkForForcedToolUsage(currentResponse, nextPayload.tool_choice) - const nextModelEndTime = Date.now() const thisModelTime = nextModelEndTime - nextModelStartTime diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index a8589d2f3bf..608a9384c8f 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -280,7 +280,7 @@ describe('Model Capabilities', () => { it.concurrent( 'should return false for providers that do not support tool usage control', () => { - const unsupportedProviders = ['ollama', 'cerebras', 'groq', 'non-existent-provider'] + const unsupportedProviders = ['ollama', 'non-existent-provider'] for (const provider of unsupportedProviders) { expect(supportsToolUsageControl(provider)).toBe(false) diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts index b1e62a8d855..4f0c6f58cc4 100644 --- a/apps/sim/providers/utils.ts +++ b/apps/sim/providers/utils.ts @@ -918,7 +918,8 @@ export function trackForcedToolUsage( } else { // All forced tools have been used, switch to auto mode if (provider === 'anthropic') { - nextToolChoice = null // Anthropic requires null to remove the parameter + // Anthropic: return null to signal the parameter should be deleted/omitted + nextToolChoice = null } else if (provider === 'google') { nextToolConfig = { functionCallingConfig: { mode: 'AUTO' } } } else { From 4134ef160705882250eaab629132025eaaf7355b Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 15 Nov 2025 14:41:14 -0800 Subject: [PATCH 2/3] fix build --- apps/sim/app/api/careers/submit/route.ts | 4 ++-- apps/sim/providers/cerebras/index.ts | 1 + 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/apps/sim/app/api/careers/submit/route.ts b/apps/sim/app/api/careers/submit/route.ts index 10c1bab2d45..bf0d492e056 100644 --- a/apps/sim/app/api/careers/submit/route.ts +++ b/apps/sim/app/api/careers/submit/route.ts @@ -1,8 +1,8 @@ import { render } from '@react-email/components' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import CareersConfirmationEmail from '@/components/emails/careers-confirmation-email' -import CareersSubmissionEmail from '@/components/emails/careers-submission-email' +import CareersConfirmationEmail from '@/components/emails/careers/careers-confirmation-email' +import CareersSubmissionEmail from '@/components/emails/careers/careers-submission-email' import { sendEmail } from '@/lib/email/mailer' import { createLogger } from '@/lib/logs/console/logger' import { generateRequestId } from '@/lib/utils' diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index 247c111cba6..3ebc8b412d5 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -14,6 +14,7 @@ import { trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' +import type { CerebrasResponse } from './types' const logger = createLogger('CerebrasProvider') From 4b4ebfa7a56c78f982a64624e744f906ad06ef4f Mon Sep 17 00:00:00 2001 From: waleed Date: Sat, 15 Nov 2025 16:09:37 -0800 Subject: [PATCH 3/3] ack PR comments --- apps/sim/lib/billing/webhooks/invoices.ts | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/apps/sim/lib/billing/webhooks/invoices.ts b/apps/sim/lib/billing/webhooks/invoices.ts index c6af3f9c33c..37c17f7e316 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -7,6 +7,7 @@ import PaymentFailedEmail from '@/components/emails/billing/payment-failed-email import { calculateSubscriptionOverage } from '@/lib/billing/core/billing' import { requireStripeClient } from '@/lib/billing/stripe-client' import { sendEmail } from '@/lib/email/mailer' +import { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' import { getBaseUrl } from '@/lib/urls/utils' @@ -164,8 +165,7 @@ async function sendPaymentFailureEmails( .from(user) .where(inArray(user.id, ownerAdminIds)) - // Filter out invalid email addresses - usersToNotify = users.filter((u) => u.email?.includes('@') && u.email.length > 3) + usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) } } else { // For individual plans, notify the user @@ -176,8 +176,7 @@ async function sendPaymentFailureEmails( .limit(1) if (users.length > 0) { - // Filter out invalid email addresses - usersToNotify = users.filter((u) => u.email?.includes('@') && u.email.length > 3) + usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) } }