Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 15 additions & 1 deletion apps/sim/app/api/copilot/api-keys/validate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -292,11 +292,25 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY))

expect(res.status).toBe(200)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1', ACCOUNT_SUBSCRIPTION)
expect(mockResolveLegacyV0BillingAttribution).not.toHaveBeenCalled()
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
})

it('fails account admission closed when its subscription cannot be read', async () => {
mockGetHighestPrioritySubscription.mockImplementationOnce(
async (_userId: string, options?: { onError?: 'throw' | 'return-null' }) => {
if (options?.onError === 'throw') throw new Error('database unavailable')
return null
}
)

const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY), createRouteContext({}))

expect(res.status).toBe(500)
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})

it('rejects markerless admission on hosted Sim', async () => {
setEnvFlags({ isHosted: true })
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))
Expand Down
26 changes: 24 additions & 2 deletions apps/sim/app/api/copilot/api-keys/validate/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import {
serializeAccountBillingDecisionHeader,
serializeBillingAttributionHeader,
} from '@/lib/billing/core/attribution'
import { payerHasChatAccess } from '@/lib/billing/core/chat-access'
import {
isFreeRunPayer,
readMidRunAccountUsageVerdict,
Expand All @@ -34,7 +35,7 @@ import {
import { getHighestPrioritySubscription } from '@/lib/billing/core/plan'
import { isEnterprisePlan } from '@/lib/billing/core/subscription'
import { deriveBillingContext } from '@/lib/billing/core/usage-log'
import { resolveUsageUpgradePayload } from '@/lib/billing/usage-upgrade'
import { resolveUsageUpgradePayload, type UsageUpgradePayer } from '@/lib/billing/usage-upgrade'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
Expand Down Expand Up @@ -246,11 +247,13 @@ function resolveContinuationBilling(
}
}

/** The admitted payer's usage standing, and the payer admission reads to decide Chat access. */
async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise<{
isExceeded: boolean
currentUsage: number
limit: number
scope: string
payer: UsageUpgradePayer
accountBillingDecision?: AccountBillingDecision
}> {
if (admission.kind === 'attributed' || admission.kind === 'legacy-scoped') {
Expand All @@ -262,6 +265,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: enforcedUsage?.currentUsage ?? 0,
limit: enforcedUsage?.limit ?? 0,
scope: usage.scope ?? 'payer',
payer: admission.attribution,
}
}

Expand All @@ -276,6 +280,7 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
payer: { billingEntity: billingContext.billingEntity, payerSubscription: subscription },
accountBillingDecision: {
userId: admission.userId,
billingEntity: billingContext.billingEntity,
Expand All @@ -291,12 +296,19 @@ async function checkAdmissionUsage(admission: AdmissionBillingDecision): Promise
}
}

const usage = await checkServerSideUsageLimits(admission.userId)
const subscription = await getHighestPrioritySubscription(admission.userId, {
onError: 'throw',
})
const usage = await checkServerSideUsageLimits(admission.userId, subscription)
return {
isExceeded: usage.isExceeded,
currentUsage: usage.currentUsage,
limit: usage.limit,
scope: 'account',
payer: {
billingEntity: deriveBillingContext(admission.userId, subscription).billingEntity,
payerSubscription: subscription,
},
}
}

Expand Down Expand Up @@ -528,6 +540,16 @@ export const POST = withRouteHandler((req: NextRequest) =>
: (usage.accountBillingDecision?.billingEntity ?? { type: 'account', id: userId }),
})

// A self-hosted or key-based turn is billed to this hosted payer, whose free plan does
// not include Chat. The refusal is the admission 402 every caller already renders as
// the upgrade card.
if (!(await payerHasChatAccess(usage.payer))) {
logger.info('[API VALIDATION] Free plan does not include Chat', { userId })
span.setAttribute(TraceAttr.CopilotValidateOutcome, CopilotValidateOutcome.UsageExceeded)
span.setAttribute(TraceAttr.HttpStatusCode, 402)
return new NextResponse(null, { status: 402 })
}

if (usage.isExceeded) {
logger.info('[API VALIDATION] Usage exceeded', {
userId,
Expand Down
11 changes: 10 additions & 1 deletion apps/sim/app/api/files/public/[token]/content/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,10 @@ import {
} from '@/lib/public-shares/application'
import { enforcePublicFileRateLimit } from '@/lib/public-shares/rate-limit'
import { FILE_CACHE_CONTROL } from '@/lib/uploads/server/delivery'
import { createConditionalFileResponse } from '@/app/api/files/utils'
import {
createConditionalFileResponse,
createStoredFileStreamResponse,
} from '@/app/api/files/utils'

export const dynamic = 'force-dynamic'
const logger = createLogger('PublicFileContentAPI')
Expand Down Expand Up @@ -49,6 +52,12 @@ export const GET = withRouteHandler(
preview: parsed.data.query.preview === '1',
request,
})
if (result.kind === 'stream') {
return await createStoredFileStreamResponse(
{ ...result, cacheControl: FILE_CACHE_CONTROL.revalidate },
request.headers
)
}
return createConditionalFileResponse(
{
buffer: result.buffer,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,12 @@ import { useParams } from 'next/navigation'
import { getOrganizationSettingsHref } from '@/components/settings/navigation'
import { useSettingsNavigationTarget } from '@/components/settings/settings-navigation-provider'
import { useSession } from '@/lib/auth/client'
import { buildHostedUpgradeUrl, HOSTED_BILLING_SETTINGS_URL } from '@/lib/billing/upgrade-reasons'
import { CHAT_PLAN_REQUIRED_REASON } from '@/lib/billing/chat-plan'
import {
buildHostedUpgradeUrl,
buildUpgradeHref,
HOSTED_BILLING_SETTINGS_URL,
} from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { isBrowserAgentAvailable, sendBrowserPanelAction } from '@/lib/browser-agent/transport'
import { useDeploymentShape } from '@/lib/core/config/deployment-shape'
Expand Down Expand Up @@ -3491,7 +3496,10 @@ interface PlanUsageLimitDisplayProps {
data: UsageUpgradeTagData
}

/** The payer's plan limit: the card links to where the plan or the limit can change. */
/**
* The payer's plan limit, or a free plan that does not include Chat: the card links to where
* the plan or the limit can change, and a workspace's plan to its upgrade page.
*/
function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { data: session } = useSession()
const hostContext = useOptionalWorkspaceHostContext()
Expand All @@ -3500,14 +3508,17 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const { hosted } = useDeploymentShape()
const navigationTarget = useSettingsNavigationTarget()
const opensInNewTab = !hosted || navigationTarget === '_blank'
const planRequired = data.reason === CHAT_PLAN_REQUIRED_REASON
const buttonLabel = data.action === 'upgrade_plan' ? 'Upgrade Plan' : 'Increase Limit'

// Self-hosted plan and limit both live on the hosted account, so local
// workspace billing roles say nothing about who may change them.
const href = hosted
? organizationContext
? getOrganizationSettingsHref(organizationContext.organization.id, 'billing')
: getSettingsHref({ section: 'billing' })
: planRequired && hostContext
? buildUpgradeHref(hostContext.workspace.id)
: getSettingsHref({ section: 'billing' })
: data.action === 'upgrade_plan'
? buildHostedUpgradeUrl()
: HOSTED_BILLING_SETTINGS_URL
Expand All @@ -3519,11 +3530,15 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
const usageGate = useWorkspaceUsageGate(
data.action === 'increase_limit' && !canManageBilling ? hostContext?.workspace.id : undefined
)
const unavailableMessage = organizationContext
? 'Contact an organization admin to manage usage limits.'
: hostContext?.hostOrganizationId
? 'Contact an organization admin to manage this workspace’s usage limits.'
: 'Only the workspace owner can manage this workspace’s usage limits.'
const action = planRequired
? 'upgrade this workspace’s plan'
: 'manage this workspace’s usage limits'
const unavailableMessage =
organizationContext && !planRequired
? 'Contact an organization admin to manage usage limits.'
: organizationContext || hostContext?.hostOrganizationId
? `Contact an organization admin to ${action}.`
: `Only the workspace owner can ${action}.`

return (
<InteractionCard
Expand All @@ -3533,7 +3548,7 @@ function PlanUsageLimitDisplay({ data }: PlanUsageLimitDisplayProps) {
aria-hidden
className='size-[14px] shrink-0 text-[var(--badge-amber-text)]'
/>
Usage Limit Reached
{planRequired ? 'Paid Plan Required' : 'Usage Limit Reached'}
</span>
}
>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { InputToolbar } from '@/app/workspace/[workspaceId]/home/components/user
import type { ModelSelectorOptions } from '@/app/workspace/[workspaceId]/home/components/user-input/components/model-selector'
import { usePromptHistory } from '@/app/workspace/[workspaceId]/home/components/user-input/hooks/use-prompt-history'
import { handleMothershipAddContextEvent } from '@/app/workspace/[workspaceId]/home/components/user-input/mothership-context-event'
import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import type {
ChatMessage,
FileAttachmentForApi,
Expand Down Expand Up @@ -507,6 +508,9 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
textareaRef.current?.focus()
}

/** Refuses a send the workspace plan excludes here, before `onSubmit`, so the draft survives. */
const stopSendWithoutChatPlan = useChatPlanGate()

/** Empties the text, chips, attachments, transcript, and the saved draft in one step. */
const clearComposer = useCallback(() => {
history.reset()
Expand All @@ -527,6 +531,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
}, [resetTranscript, history.reset])

const handleSubmit = useCallback(() => {
if (stopSendWithoutChatPlan()) return
const currentFiles = filesRef.current
const currentEditor = editorRef.current

Expand All @@ -552,7 +557,7 @@ const UserInputImpl = forwardRef<UserInputHandle, UserInputProps>(function UserI
)
if (accepted === false) return
clearComposer()
}, [onSubmit, clearComposer])
}, [stopSendWithoutChatPlan, onSubmit, clearComposer])

/**
* Enter policy for the editor: mirror canSubmit's uploading guard (Enter
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
'use client'

import { useCallback } from 'react'
import { toast } from '@sim/emcn'
import { useRouter } from 'next/navigation'
import { useSession } from '@/lib/auth/client'
import { CHAT_PLAN_REQUIRED_MESSAGE } from '@/lib/billing/chat-plan'
import { buildUpgradeHref } from '@/lib/billing/upgrade-reasons'
import { canManageWorkspaceBilling } from '@/lib/billing/workspace-permissions'
import { useOptionalWorkspaceHostContext } from '@/app/workspace/[workspaceId]/providers/workspace-host-provider'

/**
* Stops a send from a workspace whose payer may not use Chat before it starts a turn that run
* admission would refuse. A member who can manage the workspace's billing goes to its upgrade
* page; anyone else is told who can upgrade. The returned callback reports whether the send was
* stopped.
*
* The verdict is the host context's `features.chat`, which the server resolves with the same
* check admission runs. A surface without a workspace host (an organization conversation), or a
* context that predates the flag, is not stopped here; admission still answers it with the
* upgrade card.
*/
export function useChatPlanGate(): () => boolean {
const hostContext = useOptionalWorkspaceHostContext()
const { data: session } = useSession()
const viewerId = session?.user?.id
const router = useRouter()

return useCallback(() => {
if (!hostContext || hostContext.features?.chat !== false) return false

if (canManageWorkspaceBilling(hostContext, viewerId)) {
router.push(buildUpgradeHref(hostContext.workspace.id))
} else {
toast.error(
hostContext.hostOrganizationId
? `${CHAT_PLAN_REQUIRED_MESSAGE} Contact an organization admin to upgrade this workspace’s plan.`
: `${CHAT_PLAN_REQUIRED_MESSAGE} Ask the workspace owner to upgrade this workspace’s plan.`
)
}
return true
}, [hostContext, viewerId, router])
}
4 changes: 4 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,7 @@ import {
dispatchStreamEvent,
finalizeResidualToolCalls,
} from '@/app/workspace/[workspaceId]/home/hooks/stream'
import { useChatPlanGate } from '@/app/workspace/[workspaceId]/home/hooks/use-chat-plan-gate'
import { useNativeActiveTabIds } from '@/app/workspace/[workspaceId]/home/hooks/use-desktop-tab-resources'
import { useMothershipChatOlderMessages } from '@/app/workspace/[workspaceId]/home/hooks/use-mothership-chat-older-messages'
import { resolveEffectiveResourceId } from '@/app/workspace/[workspaceId]/home/resource-view-policy'
Expand Down Expand Up @@ -779,6 +780,7 @@ export function useChat(
const pathname = usePathname()
const router = useRouter()
const queryClient = useQueryClient()
const stopSendWithoutChatPlan = useChatPlanGate()
const [pendingMessages, setPendingMessages] = useState<ChatMessage[]>([])
const [completedTurn, setCompletedTurn] = useState<CompletedTurn | null>(null)
const completedTurnRef = useRef<CompletedTurn | null>(null)
Expand Down Expand Up @@ -4437,6 +4439,7 @@ export function useChat(
options?: SendMessageOptions
) => {
if ((!message.trim() && !fileAttachments?.length) || !scopeKey) return
if (stopSendWithoutChatPlan()) return

const queueStore = useMothershipQueueStore.getState()
const activeChatKey = chatKeyRef.current
Expand Down Expand Up @@ -4524,6 +4527,7 @@ export function useChat(
},
[
workspaceId,
stopSendWithoutChatPlan,
createQueuedMessage,
startSendMessage,
handOffWithdrawnSend,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -176,6 +176,10 @@ export const COMPARISON_SECTIONS: ComparisonSection[] = [
{
title: 'Features',
rows: [
{
label: 'Chat with Sim',
values: [false, true, true, true],
Comment thread
waleedlatif1 marked this conversation as resolved.
Resolved
},
{
label: 'Sim Mailer (Inbox)',
values: [false, false, true, true],
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/workspace/[workspaceId]/upgrade/plan-configs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ export const ENTERPRISE_PLAN_CREDITS: PlanCredits = {
}

export const PRO_PLAN_FEATURES: readonly string[] = [
'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.pro.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Higher rate limits',
Expand All @@ -35,6 +36,7 @@ export const PRO_PLAN_FEATURES: readonly string[] = [
]

export const MAX_PLAN_FEATURES: readonly string[] = [
'Chat with Sim',
`${DEFAULT_BILLING_CONCURRENCY_LIMITS.team.toLocaleString('en-US')} concurrent executions`,
'Invite teammates',
'Sim Mailer, KB Live Sync & Sandboxes',
Expand Down
17 changes: 11 additions & 6 deletions apps/sim/hooks/use-file-content-source.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -146,12 +146,17 @@ export function createPublicFileContentSource(
contentUrl: string,
owner?: EditableFileOwner
): FileContentSource {
return inlineImageSource(
(_key, opts) =>
opts?.preview ? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1` : contentUrl,
`/api/files/public/${token}/inline`,
owner
)
return {
...inlineImageSource(
(_key, opts) =>
opts?.preview
? `${contentUrl}${contentUrl.includes('?') ? '&' : '?'}preview=1`
: contentUrl,
`/api/files/public/${token}/inline`,
owner
),
servesByteRanges: true,
}
}

/**
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/api/contracts/workspaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -288,6 +288,11 @@ export const workspaceHostContextSchema = z.object({
knowledgeMemberAccess: z.boolean().optional(),
/** Optional for rolling compatibility with app versions that predate administrator mode. */
knowledgeSourceMirroredAccess: z.boolean().optional(),
/**
* Whether the workspace payer may use Chat. Optional for rolling compatibility; absent
* leaves the composer open, and run admission still decides.
*/
chat: z.boolean().optional(),
})
.optional(),
/** Optional for rolling compatibility with app versions that predate deployment projection. */
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/billing/chat-plan.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
/** The upgrade card's `reason` for a payer whose plan does not include Chat. */
export const CHAT_PLAN_REQUIRED_REASON = 'plan_required' as const

/** Lead sentence of every refusal that withholds Chat from a plan. */
export const CHAT_PLAN_REQUIRED_MESSAGE = 'Chat is included on paid plans.'
Loading
Loading