diff --git a/apps/sim/lib/billing/sandbox-pricing.test.ts b/apps/sim/lib/billing/sandbox-pricing.test.ts index e523120bc32..2a38f397d4c 100644 --- a/apps/sim/lib/billing/sandbox-pricing.test.ts +++ b/apps/sim/lib/billing/sandbox-pricing.test.ts @@ -1,6 +1,20 @@ -import { describe, expect, it } from 'vitest' +/** + * @vitest-environment node + */ +import { resetEnvMock, setEnv } from '@sim/testing/mocks/env.mock' +import { afterAll, afterEach, describe, expect, it, vi } from 'vitest' + +vi.hoisted(() => { + vi.stubEnv('NODE_ENV', 'production') +}) + +vi.unmock('@/lib/core/config/env-flags') + import { createSandboxPricing, priceSandboxUsage } from '@/lib/billing/sandbox-pricing' +afterEach(resetEnvMock) +afterAll(() => vi.unstubAllEnvs()) + describe('sandbox pricing', () => { it.each([ ['e2b', 0.1656], @@ -33,4 +47,32 @@ describe('sandbox pricing', () => { 'finite nonnegative' ) }) + + describe('default multiplier from the production environment', () => { + it('coerces the string COST_MULTIPLIER that process.env delivers', () => { + setEnv({ COST_MULTIPLIER: '1.1' }) + + const pricing = createSandboxPricing('e2b') + + expect(pricing.multiplier).toBe(1.1) + expect(priceSandboxUsage(pricing, 1000, 1000).billedCost).toBeCloseTo(0.0000506, 8) + }) + + it('falls back to 1 when COST_MULTIPLIER is unset', () => { + setEnv({ COST_MULTIPLIER: undefined }) + + expect(createSandboxPricing('daytona').multiplier).toBe(1) + }) + + it('falls back to 1 instead of throwing when COST_MULTIPLIER is not a nonnegative number', () => { + setEnv({ COST_MULTIPLIER: 'abc' }) + expect(createSandboxPricing('e2b').multiplier).toBe(1) + + setEnv({ COST_MULTIPLIER: '-2' }) + expect(createSandboxPricing('e2b').multiplier).toBe(1) + + setEnv({ COST_MULTIPLIER: ' ' }) + expect(createSandboxPricing('e2b').multiplier).toBe(1) + }) + }) }) diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index a501ceb7a5e..1a954327eea 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -14,7 +14,7 @@ import { resolveEnterpriseEntitlement, resolveSandboxFeatureAvailability, } from './enterprise-entitlements' -import { env, envBoolean, getEnv, isFalsy, isTruthy } from './env' +import { env, envBoolean, envNumber, getEnv, isFalsy, isTruthy } from './env' import { hasEnvCapabilityValue, inspectCapability, SANDBOX_CAPABILITY } from './env-capabilities' /** @@ -684,8 +684,13 @@ export function getAllowedMcpDomainsFromEnv(): string[] | null { } /** - * Get cost multiplier based on environment + * Get cost multiplier based on environment. + * + * `COST_MULTIPLIER` is declared as a number but arrives as a string from + * `process.env` because `createEnv` skips validation, so it is normalized + * through {@link envNumber}. Unset, empty, non-numeric, and negative values + * fall back to 1. */ export function getCostMultiplier(): number { - return isProd ? (env.COST_MULTIPLIER ?? 1) : 1 + return isProd ? envNumber(env.COST_MULTIPLIER, 1) : 1 } diff --git a/apps/sim/lib/core/config/env.test.ts b/apps/sim/lib/core/config/env.test.ts index ea792ba42d8..d0a5b98d8d6 100644 --- a/apps/sim/lib/core/config/env.test.ts +++ b/apps/sim/lib/core/config/env.test.ts @@ -12,4 +12,11 @@ describe('envNumber', () => { expect(envNumber('5.5', 1, { min: 1, integer: true })).toBe(1) expect(envNumber(5.5, 1, { min: 1, integer: true })).toBe(1) }) + + it('treats whitespace-only values as unset instead of coercing them to 0', () => { + expect(envNumber(' ', 1)).toBe(1) + expect(envNumber('', 1)).toBe(1) + expect(envNumber(' 1.1 ', 1)).toBe(1.1) + expect(envNumber('0', 1)).toBe(0) + }) }) diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index a3992c6cbd9..833ad743b79 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -813,7 +813,7 @@ export function envNumber( ) { return value } - if (value === undefined || value === null || value === '') return fallback + if (value === undefined || value === null || String(value).trim() === '') return fallback const parsed = Number(value) return Number.isFinite(parsed) && parsed >= min && (!options.integer || Number.isInteger(parsed)) ? parsed diff --git a/packages/testing/src/mocks/env.mock.ts b/packages/testing/src/mocks/env.mock.ts index 13ede7a5d41..8114dc86905 100644 --- a/packages/testing/src/mocks/env.mock.ts +++ b/packages/testing/src/mocks/env.mock.ts @@ -136,7 +136,7 @@ export function envNumberImpl( ) { return value } - if (value === undefined || value === null || value === '') return fallback + if (value === undefined || value === null || String(value).trim() === '') return fallback const parsed = Number(value) return Number.isFinite(parsed) && parsed >= min && (!options.integer || Number.isInteger(parsed)) ? parsed