diff --git a/apps/sim/app/api/careers/submit/route.ts b/apps/sim/app/api/careers/submit/route.ts index 10c1bab2d4..bf0d492e05 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/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 ffd21cfcb2..845112ad3e 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 8faf32af0f..37ec50e7a3 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 238c278f7b..fbc61919b1 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 2169c78e57..25c4c61df4 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 0bf3ceb3da..0e79ebf392 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 ba4df7e57c..e4b5c9ed09 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 2f7e7f3721..003c683795 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 e7ee7eb38a..274be344bf 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 6ecd1e385d..34dfacee8d 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 1979682b82..dc09fe20dc 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 0000000000..592f4a1323 --- /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 0000000000..1d7f41810f --- /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 25bd1a9fac..ca3745cbfb 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 96e46f69af..d1b9f3b122 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 bd931d669f..0577686da6 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 96246efbcd..5d3e79d89b 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 800dc92380..d2d7d70d0d 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 8c439130cb..313a74f145 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 cf18244d5f..fee8b81773 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 106cdf6609..7487553914 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 ed18f442a9..f07ad3552c 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 f0748623a4..749be28a20 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 8d219a8b9e..2a0eab57f4 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 a2d6d08e93..37c17f7e31 100644 --- a/apps/sim/lib/billing/webhooks/invoices.ts +++ b/apps/sim/lib/billing/webhooks/invoices.ts @@ -1,10 +1,15 @@ +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 { quickValidateEmail } from '@/lib/email/validation' import { createLogger } from '@/lib/logs/console/logger' +import { getBaseUrl } from '@/lib/urls/utils' const logger = createLogger('StripeInvoiceWebhooks') @@ -19,6 +24,199 @@ 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)) + + usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) + } + } 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) { + usersToNotify = users.filter((u) => u.email && quickValidateEmail(u.email).isValid) + } + } + + // 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 +435,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 +507,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 717d0babc1..3ebc8b412d 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -8,8 +8,13 @@ import type { ProviderResponse, TimeSegment, } from '@/providers/types' -import { prepareToolExecution } from '@/providers/utils' +import { + prepareToolExecution, + prepareToolsWithUsageControl, + trackForcedToolUsage, +} from '@/providers/utils' import { executeTool } from '@/tools' +import type { CerebrasResponse } from './types' const logger = createLogger('CerebrasProvider') @@ -116,29 +121,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 +362,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 085687941c..02683f9fe4 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 d9ac569d21..027f501920 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 ceeebc70a6..00579a61ef 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 35fc219de0..c1f2820462 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 a8589d2f3b..608a9384c8 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 b1e62a8d85..4f0c6f58cc 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 {