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
15 changes: 15 additions & 0 deletions apps/docs/content/docs/en/execution/costs.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,21 @@ Use your own API keys for AI model providers instead of Sim's hosted keys to pay

When configured, workflows use your key instead of Sim's hosted keys. If removed, workflows automatically fall back to hosted keys with the multiplier.

## Voice Input

Voice input uses ElevenLabs Scribe v2 Realtime for speech-to-text transcription. It is available in the Mothership chat and in deployed chat voice mode.

| Context | Cost per session | Max duration |
|---------|-----------------|--------------|
| Mothership (workspace) | ~5 credits ($0.024) | 3 minutes |
| Deployed chat (voice mode) | ~2 credits ($0.008) | 1 minute |

Each voice session is billed when it starts. In deployed chat voice mode, each conversation turn (speak → agent responds → speak again) is a separate session. Multi-turn conversations are billed per turn.

<Callout type="info">
Voice input requires `ELEVENLABS_API_KEY` to be configured. When the key is not set, voice input controls are hidden.
</Callout>

## Plans

Sim has two paid plan tiers — **Pro** and **Max**. Either can be used individually or with a team. Team plans pool credits across all seats in the organization.
Expand Down
2 changes: 2 additions & 0 deletions apps/sim/app/_styles/globals.css
Original file line number Diff line number Diff line change
Expand Up @@ -220,6 +220,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #1a5cf6;
--warning: #ea580c;

Expand Down Expand Up @@ -375,6 +376,7 @@ html[data-sidebar-collapsed] .sidebar-container .sidebar-collapse-btn {
/* Brand & state */
--brand-secondary: #33b4ff;
--brand-accent: #33c482;
--brand-accent-hover: #2dac72;
--selection: #4b83f7;
--warning: #ff6600;

Expand Down
6 changes: 3 additions & 3 deletions apps/sim/app/api/a2a/serve/[agentId]/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
import { type AuthResult, AuthType, checkHybridAuth } from '@/lib/auth/hybrid'
import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis'
import { validateUrlWithDNS } from '@/lib/core/security/input-validation.server'
import { getClientIp } from '@/lib/core/utils/request'
import { SSE_HEADERS } from '@/lib/core/utils/sse'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { generateId } from '@/lib/core/utils/uuid'
Expand Down Expand Up @@ -52,10 +53,9 @@ function getCallerFingerprint(request: NextRequest, userId?: string | null): str
return `user:${userId}`
}

const forwardedFor = request.headers.get('x-forwarded-for')?.split(',')[0]?.trim()
const realIp = request.headers.get('x-real-ip')?.trim()
const clientIp = getClientIp(request)
const userAgent = request.headers.get('user-agent')?.trim() || 'unknown'
return `public:${forwardedFor || realIp || 'unknown'}:${userAgent}`
return `public:${clientIp}:${userAgent}`
}

function hasCallerAccessToTask(
Expand Down
9 changes: 8 additions & 1 deletion apps/sim/app/api/auth/socket-token/route.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createLogger } from '@sim/logger'
import { headers } from 'next/headers'
import { NextResponse } from 'next/server'
import { auth } from '@/lib/auth'
import { isAuthDisabled } from '@/lib/core/config/feature-flags'

const logger = createLogger('SocketTokenAPI')

export async function POST() {
if (isAuthDisabled) {
return NextResponse.json({ token: 'anonymous-socket-token' })
Expand All @@ -19,7 +22,11 @@ export async function POST() {
}

return NextResponse.json({ token: response.token })
} catch {
} catch (error) {
logger.error('Failed to generate socket token', {
error: error instanceof Error ? error.message : String(error),
stack: error instanceof Error ? error.stack : undefined,
})
return NextResponse.json({ error: 'Failed to generate token' }, { status: 500 })
}
}
4 changes: 2 additions & 2 deletions apps/sim/app/api/demo-requests/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { type NextRequest, NextResponse } from 'next/server'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import { getFromEmailAddress } from '@/lib/messaging/email/utils'
Expand All @@ -25,7 +25,7 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()

try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const ip = getClientIp(req)
const storageKey = `public:demo-request:${ip}`

const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
Expand Down
4 changes: 2 additions & 2 deletions apps/sim/app/api/help/integration-request/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { z } from 'zod'
import { env } from '@/lib/core/config/env'
import type { TokenBucketConfig } from '@/lib/core/rate-limiter'
import { RateLimiter } from '@/lib/core/rate-limiter'
import { generateRequestId } from '@/lib/core/utils/request'
import { generateRequestId, getClientIp } from '@/lib/core/utils/request'
import { getEmailDomain } from '@/lib/core/utils/urls'
import { sendEmail } from '@/lib/messaging/email/mailer'
import {
Expand Down Expand Up @@ -37,7 +37,7 @@ export async function POST(req: NextRequest) {
const requestId = generateRequestId()

try {
const ip = req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ?? 'unknown'
const ip = getClientIp(req)
const storageKey = `public:integration-request:${ip}`

const { allowed, remaining, resetAt } = await rateLimiter.checkRateLimitDirect(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -222,6 +222,13 @@ export async function PATCH(request: NextRequest, { params }: RouteParams) {
}
if (parsed.data.status !== undefined) {
updates.status = parsed.data.status
if (parsed.data.status === 'active') {
updates.consecutiveFailures = 0
updates.lastSyncError = null
if (updates.nextSyncAt === undefined) {
updates.nextSyncAt = new Date()
}
}
}

await db
Expand Down
213 changes: 213 additions & 0 deletions apps/sim/app/api/organizations/[id]/whitelabel/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,213 @@
import { db } from '@sim/db'
import { member, organization } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { and, eq } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { z } from 'zod'
import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log'
import { getSession } from '@/lib/auth'
import { isOrganizationOnEnterprisePlan } from '@/lib/billing/core/subscription'
import { HEX_COLOR_REGEX } from '@/lib/branding'
import type { OrganizationWhitelabelSettings } from '@/lib/branding/types'

const logger = createLogger('WhitelabelAPI')

const updateWhitelabelSchema = z.object({
brandName: z
.string()
.trim()
.max(64, 'Brand name must be 64 characters or fewer')
.nullable()
.optional(),
logoUrl: z.string().min(1).nullable().optional(),
wordmarkUrl: z.string().min(1).nullable().optional(),
primaryColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary color must be a valid hex color (e.g. #701ffc)')
.nullable()
.optional(),
primaryHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Primary hover color must be a valid hex color')
.nullable()
.optional(),
accentColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent color must be a valid hex color')
.nullable()
.optional(),
accentHoverColor: z
.string()
.regex(HEX_COLOR_REGEX, 'Accent hover color must be a valid hex color')
.nullable()
.optional(),
supportEmail: z
.string()
.email('Support email must be a valid email address')
.nullable()
.optional(),
documentationUrl: z.string().url('Documentation URL must be a valid URL').nullable().optional(),
termsUrl: z.string().url('Terms URL must be a valid URL').nullable().optional(),
privacyUrl: z.string().url('Privacy URL must be a valid URL').nullable().optional(),
hidePoweredBySim: z.boolean().optional(),
})

/**
* GET /api/organizations/[id]/whitelabel
* Returns the organization's whitelabel settings.
* Accessible by any member of the organization.
*/
export async function GET(_request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: organizationId } = await params

const [memberEntry] = await db
.select({ id: member.id })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)

if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}

const [org] = await db
.select({ whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)

if (!org) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

return NextResponse.json({
success: true,
data: (org.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to get whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}

/**
* PUT /api/organizations/[id]/whitelabel
* Updates the organization's whitelabel settings.
* Requires enterprise plan and owner/admin role.
*/
export async function PUT(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
try {
const session = await getSession()

if (!session?.user?.id) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}

const { id: organizationId } = await params

const body = await request.json()
const parsed = updateWhitelabelSchema.safeParse(body)

if (!parsed.success) {
return NextResponse.json(
{ error: parsed.error.errors[0]?.message ?? 'Invalid request body' },
{ status: 400 }
)
}

const [memberEntry] = await db
.select({ role: member.role })
.from(member)
.where(and(eq(member.organizationId, organizationId), eq(member.userId, session.user.id)))
.limit(1)

if (!memberEntry) {
return NextResponse.json(
{ error: 'Forbidden - Not a member of this organization' },
{ status: 403 }
)
}

if (memberEntry.role !== 'owner' && memberEntry.role !== 'admin') {
return NextResponse.json(
{ error: 'Forbidden - Only organization owners and admins can update whitelabel settings' },
{ status: 403 }
)
}

const hasEnterprisePlan = await isOrganizationOnEnterprisePlan(organizationId)

if (!hasEnterprisePlan) {
return NextResponse.json(
{ error: 'Whitelabeling is available on Enterprise plans only' },
{ status: 403 }
)
}

const [currentOrg] = await db
.select({ name: organization.name, whitelabelSettings: organization.whitelabelSettings })
.from(organization)
.where(eq(organization.id, organizationId))
.limit(1)

if (!currentOrg) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

const current: OrganizationWhitelabelSettings = currentOrg.whitelabelSettings ?? {}
const incoming = parsed.data

const merged: OrganizationWhitelabelSettings = { ...current }

for (const key of Object.keys(incoming) as Array<keyof typeof incoming>) {
const value = incoming[key]
if (value === null) {
delete merged[key as keyof OrganizationWhitelabelSettings]
} else if (value !== undefined) {
;(merged as Record<string, unknown>)[key] = value
}
}

const [updated] = await db
.update(organization)
.set({ whitelabelSettings: merged, updatedAt: new Date() })
.where(eq(organization.id, organizationId))
.returning({ whitelabelSettings: organization.whitelabelSettings })

if (!updated) {
return NextResponse.json({ error: 'Organization not found' }, { status: 404 })
}

recordAudit({
workspaceId: null,
actorId: session.user.id,
action: AuditAction.ORGANIZATION_UPDATED,
resourceType: AuditResourceType.ORGANIZATION,
resourceId: organizationId,
actorName: session.user.name ?? undefined,
actorEmail: session.user.email ?? undefined,
resourceName: currentOrg.name,
description: 'Updated organization whitelabel settings',
metadata: { changes: Object.keys(incoming) },
request,
})

return NextResponse.json({
success: true,
data: (updated.whitelabelSettings ?? {}) as OrganizationWhitelabelSettings,
})
} catch (error) {
logger.error('Failed to update whitelabel settings', { error })
return NextResponse.json({ error: 'Internal server error' }, { status: 500 })
}
}
11 changes: 11 additions & 0 deletions apps/sim/app/api/settings/voice/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { NextResponse } from 'next/server'
import { hasSTTService } from '@/lib/speech/config'

/**
* Returns whether server-side STT is configured.
* Unauthenticated — the response is a single boolean,
* not sensitive data, and deployed chat visitors need it.
*/
export async function GET() {
return NextResponse.json({ sttAvailable: hasSTTService() })
}
Loading
Loading