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
58 changes: 30 additions & 28 deletions apps/sim/app/api/billing/update-cost/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ const ATTRIBUTION = {
payerSubscription: null,
}

const OLD_GO_HOSTED_UPDATE_COST_BODY = {
const SELF_HOSTED_UPDATE_COST_BODY = {
userId: 'user-1',
cost: 0.4662453,
model: 'claude-opus-4.8',
Expand All @@ -117,11 +117,11 @@ const OLD_GO_HOSTED_UPDATE_COST_BODY = {
} as const

const EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY = {
...OLD_GO_HOSTED_UPDATE_COST_BODY,
...SELF_HOSTED_UPDATE_COST_BODY,
idempotencyKey: 'explicit-legacy-billing-id',
} as const

const OLD_GO_WORKSPACELESS_UPDATE_COST_BODY = {
const SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY = {
userId: 'user-1',
cost: 0.5,
model: 'gpt',
Expand All @@ -131,8 +131,8 @@ const OLD_GO_WORKSPACELESS_UPDATE_COST_BODY = {
idempotencyKey: 'random-old-go-direct-billing-id',
} as const

const OLD_GO_OPAQUE_WORKSPACE_UPDATE_COST_BODY = {
...OLD_GO_WORKSPACELESS_UPDATE_COST_BODY,
const SELF_HOSTED_OPAQUE_WORKSPACE_UPDATE_COST_BODY = {
...SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY,
workspaceId: 'local-self-hosted-workspace',
} as const

Expand All @@ -148,7 +148,7 @@ const KEYLESS_UPDATE_COST_BODY = {
describe('POST /api/billing/update-cost — workspaceId attribution', () => {
beforeEach(() => {
vi.clearAllMocks()
setEnvFlags({ isBillingEnabled: true, isCopilotBillingProtocolRequired: false })
setEnvFlags({ isBillingEnabled: true, isHosted: false })
mockCheckInternalApiKey.mockReturnValue({ success: true })
mockRecordCumulativeUsage.mockResolvedValue({ billed: true, delta: 0.5, total: 0.5 })
mockCheckAndBillOverageThreshold.mockResolvedValue(undefined)
Expand Down Expand Up @@ -187,7 +187,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})

it('returns no-op success for old markerless Go when billing is disabled', async () => {
it('returns no-op success for markerless local self-hosted Go when billing is disabled', async () => {
setEnvFlags({ isBillingEnabled: false })

const res = await POST(
Expand All @@ -213,13 +213,13 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})

it('keeps the exact old-Go callback bodies contract-compatible', () => {
expect(billingUpdateCostBodySchema.safeParse(OLD_GO_HOSTED_UPDATE_COST_BODY).success).toBe(true)
it('keeps local self-hosted callback bodies contract-compatible', () => {
expect(billingUpdateCostBodySchema.safeParse(SELF_HOSTED_UPDATE_COST_BODY).success).toBe(true)
expect(
billingUpdateCostBodySchema.safeParse(OLD_GO_WORKSPACELESS_UPDATE_COST_BODY).success
billingUpdateCostBodySchema.safeParse(SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY).success
).toBe(true)
expect(
billingUpdateCostBodySchema.safeParse(OLD_GO_OPAQUE_WORKSPACE_UPDATE_COST_BODY).success
billingUpdateCostBodySchema.safeParse(SELF_HOSTED_OPAQUE_WORKSPACE_UPDATE_COST_BODY).success
).toBe(true)
})

Expand All @@ -234,9 +234,9 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
})

it('bills the routed workspace payer for the exact markerless hosted callback', async () => {
it('bills the routed workspace payer for a markerless self-hosted callback', async () => {
const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
)

expect(res.status).toBe(200)
Expand Down Expand Up @@ -277,7 +277,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
)

const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
)

expect(res.status).toBe(503)
Expand All @@ -290,10 +290,10 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
})
})

it('rejects markerless callbacks only when protocol-required is explicitly enabled', async () => {
setEnvFlags({ isCopilotBillingProtocolRequired: true })
it('rejects markerless callbacks on hosted Sim', async () => {
setEnvFlags({ isHosted: true })
const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
)

expect(res.status).toBe(400)
Expand All @@ -303,7 +303,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {

it('does not let markerless legacy traffic fall through to a modern attribution envelope', async () => {
const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, {
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, {
'x-api-key': 'internal',
'x-sim-billing-attribution': 'serialized-attribution',
})
Expand All @@ -316,7 +316,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
})

it('rejects explicitly labeled legacy callbacks without admission attribution', async () => {
setEnvFlags({ isCopilotBillingProtocolRequired: true })
setEnvFlags({ isHosted: true })
const res = await POST(
createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, {
'x-api-key': 'internal',
Expand All @@ -331,7 +331,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
})

it('bills explicitly labeled legacy callbacks from their admission attribution', async () => {
setEnvFlags({ isCopilotBillingProtocolRequired: true })
setEnvFlags({ isHosted: true })
const res = await POST(
createMockRequest('POST', EXPLICIT_LEGACY_HOSTED_UPDATE_COST_BODY, {
'x-api-key': 'internal',
Expand Down Expand Up @@ -507,13 +507,13 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockRecordCumulativeUsage).not.toHaveBeenCalled()
})

it('does not expose context-mismatch 409 to markerless old Go', async () => {
it('does not expose context-mismatch 409 to markerless self-hosted Go', async () => {
mockRecordCumulativeUsage.mockRejectedValue(
new MockCumulativeUsageContextMismatchError('different billing context')
)

const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
)

expect(res.status).toBe(500)
Expand All @@ -525,10 +525,10 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
expect(mockCheckAndBillPayerOverageThreshold).not.toHaveBeenCalled()
})

it('preserves old Go duplicate-compatible 409 semantics for markerless callbacks', async () => {
it('preserves duplicate-compatible 409 semantics for markerless self-hosted callbacks', async () => {
mockRecordCumulativeUsage.mockResolvedValue({ billed: false, delta: 0, total: 0.4662453 })
const res = await POST(
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
)
expect(res.status).toBe(409)
await expect(res.json()).resolves.toMatchObject({
Expand Down Expand Up @@ -558,7 +558,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
.mockRejectedValueOnce(new Error('Threshold settlement unavailable'))
.mockResolvedValueOnce(undefined)
const createRequest = () =>
createMockRequest('POST', OLD_GO_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_UPDATE_COST_BODY, { 'x-api-key': 'internal' })

const firstResponse = await POST(createRequest())
const retryResponse = await POST(createRequest())
Expand Down Expand Up @@ -669,9 +669,11 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
)
})

it('preserves account-ledger ownership for the exact workspace-less old-Go callback', async () => {
it('preserves account-ledger ownership for a workspace-less self-hosted callback', async () => {
const res = await POST(
createMockRequest('POST', OLD_GO_WORKSPACELESS_UPDATE_COST_BODY, { 'x-api-key': 'internal' })
createMockRequest('POST', SELF_HOSTED_WORKSPACELESS_UPDATE_COST_BODY, {
'x-api-key': 'internal',
})
)

expect(res.status).toBe(200)
Expand All @@ -691,7 +693,7 @@ describe('POST /api/billing/update-cost — workspaceId attribution', () => {
it('preserves account-ledger ownership for an opaque direct legacy workspace', async () => {
mockResolveLegacyV0BillingAttribution.mockResolvedValueOnce(null)
const res = await POST(
createMockRequest('POST', OLD_GO_OPAQUE_WORKSPACE_UPDATE_COST_BODY, {
createMockRequest('POST', SELF_HOSTED_OPAQUE_WORKSPACE_UPDATE_COST_BODY, {
'x-api-key': 'internal',
})
)
Expand Down
12 changes: 5 additions & 7 deletions apps/sim/app/api/billing/update-cost/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@ import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1'
import { TraceSpan } from '@/lib/copilot/generated/trace-spans-v1'
import { checkInternalApiKey } from '@/lib/copilot/request/http'
import { withIncomingGoSpan } from '@/lib/copilot/request/otel'
import { isBillingEnabled, isCopilotBillingProtocolRequired } from '@/lib/core/config/env-flags'
import { isBillingEnabled, isHosted } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'

Expand Down Expand Up @@ -163,7 +163,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon
const suppliedAttributionHeader = parsed.data.headers?.[BILLING_ATTRIBUTION_HEADER]
const suppliedAccountDecisionHeader = parsed.data.headers?.[BILLING_ACCOUNT_DECISION_HEADER]
const isMarkerlessLegacy = requestedProtocol === undefined
if (isMarkerlessLegacy && isCopilotBillingProtocolRequired) {
if (isMarkerlessLegacy && isHosted) {
return invalidBillingProtocolResponse(requestId, span)
}
const protocol: CopilotBillingProtocol = requestedProtocol ?? COPILOT_BILLING_PROTOCOL.legacy
Expand Down Expand Up @@ -229,11 +229,9 @@ async function updateCostInner(req: NextRequest, span: Span): Promise<NextRespon

let billingAttribution = suppliedBillingAttribution
/**
* Old Go creates its random idempotency key after admission and returns no
* protocol or payer envelope. The markerless legacy-v0 path therefore
* re-resolves a locally known workspace at callback time. This mutable
* compatibility semantic is intentionally unreachable from modern
* attributed-v1/direct-v1 callbacks.
* Local self-hosted markerless callbacks have no immutable payer envelope,
* so they re-resolve a locally known workspace at callback time. Hosted
* attributed-v1/direct-v1 callbacks can never reach this mutable path.
*/
if (isMarkerlessLegacy && workspaceId) {
billingAttribution =
Expand Down
52 changes: 25 additions & 27 deletions apps/sim/app/api/copilot/api-keys/validate/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -68,16 +68,16 @@ const ACCOUNT_BILLING_DECISION = {
},
}

const OLD_GO_HOSTED_VALIDATE_BODY = {
const SELF_HOSTED_VALIDATE_BODY = {
userId: 'user-1',
workspaceId: 'ws-1',
} as const

const OLD_GO_WORKSPACELESS_VALIDATE_BODY = {
const SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY = {
userId: 'user-1',
} as const

const OLD_GO_OPAQUE_WORKSPACE_VALIDATE_BODY = {
const SELF_HOSTED_OPAQUE_WORKSPACE_VALIDATE_BODY = {
userId: 'user-1',
workspaceId: 'local-self-hosted-workspace',
} as const
Expand Down Expand Up @@ -151,7 +151,7 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
beforeEach(() => {
vi.clearAllMocks()
resetDbChainMock()
setEnvFlags({ isCopilotBillingProtocolRequired: false })
setEnvFlags({ isHosted: false })
mockCheckInternalApiKey.mockReturnValue({ success: true })
queueTableRows(schemaMock.user, [{ id: 'user-1' }])
mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION)
Expand Down Expand Up @@ -199,25 +199,23 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
resetDbChainMock()
})

it('keeps the exact old-Go validate bodies contract-compatible', () => {
expect(validateCopilotApiKeyBodySchema.safeParse(OLD_GO_HOSTED_VALIDATE_BODY).success).toBe(
true
)
it('keeps local self-hosted validate bodies contract-compatible', () => {
expect(validateCopilotApiKeyBodySchema.safeParse(SELF_HOSTED_VALIDATE_BODY).success).toBe(true)
expect(
validateCopilotApiKeyBodySchema.safeParse(OLD_GO_WORKSPACELESS_VALIDATE_BODY).success
validateCopilotApiKeyBodySchema.safeParse(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY).success
).toBe(true)
expect(
validateCopilotApiKeyBodySchema.safeParse(OLD_GO_OPAQUE_WORKSPACE_VALIDATE_BODY).success
validateCopilotApiKeyBodySchema.safeParse(SELF_HOSTED_OPAQUE_WORKSPACE_VALIDATE_BODY).success
).toBe(true)
})

it('checks the routed workspace payer pool for exact markerless hosted admission', async () => {
it('checks the routed workspace payer pool for markerless self-hosted admission', async () => {
mockCheckAttributedUsageLimits.mockResolvedValue({
isExceeded: true,
payerUsage: { currentUsage: 200, limit: 100 },
scope: 'payer',
})
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(402)
expect(mockResolveLegacyV0BillingAttribution).toHaveBeenCalledWith({
Expand All @@ -228,20 +226,20 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
})

it('preserves the exact actor member cap for markerless hosted admission', async () => {
it('preserves the actor member cap for markerless self-hosted admission', async () => {
mockCheckAttributedUsageLimits.mockResolvedValue({
isExceeded: true,
payerUsage: { currentUsage: 20, limit: 100 },
memberUsage: { currentUsage: 5, limit: 4 },
scope: 'member',
})
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(402)
})

it('accepts the exact markerless hosted body under its routed workspace limits', async () => {
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
it('accepts markerless self-hosted admission under its routed workspace limits', async () => {
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(200)
expect(res.headers.get('x-sim-billing-attribution')).toBeNull()
Expand All @@ -251,22 +249,22 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {
it('returns whether the validated key owner has an enterprise account', async () => {
mockIsEnterprisePlan.mockResolvedValueOnce(true)

const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ isEnterprise: true })
expect(mockIsEnterprisePlan).toHaveBeenCalledWith('user-1')
})

it('returns false when the validated key owner is not enterprise', async () => {
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(200)
await expect(res.json()).resolves.toEqual({ isEnterprise: false })
})

it('preserves account admission for the exact workspace-less old-Go body', async () => {
const res = await POST(request(OLD_GO_WORKSPACELESS_VALIDATE_BODY))
it('preserves account admission for a workspace-less self-hosted body', async () => {
const res = await POST(request(SELF_HOSTED_WORKSPACELESS_VALIDATE_BODY))

expect(res.status).toBe(200)
expect(mockCheckServerSideUsageLimits).toHaveBeenCalledWith('user-1')
Expand All @@ -276,26 +274,26 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => {

it('preserves account admission for an opaque direct legacy workspace', async () => {
mockResolveLegacyV0BillingAttribution.mockResolvedValueOnce(null)
const res = await POST(request(OLD_GO_OPAQUE_WORKSPACE_VALIDATE_BODY))
const res = await POST(request(SELF_HOSTED_OPAQUE_WORKSPACE_VALIDATE_BODY))

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

it('rejects markerless admission only when protocol-required is explicitly enabled', async () => {
setEnvFlags({ isCopilotBillingProtocolRequired: true })
const res = await POST(request(OLD_GO_HOSTED_VALIDATE_BODY))
it('rejects markerless admission on hosted Sim', async () => {
setEnvFlags({ isHosted: true })
const res = await POST(request(SELF_HOSTED_VALIDATE_BODY))

expect(res.status).toBe(400)
expect(mockCheckServerSideUsageLimits).not.toHaveBeenCalled()
expect(mockCheckAttributedUsageLimits).not.toHaveBeenCalled()
})

it('allows explicitly labeled legacy requests when markerless traffic is disabled', async () => {
setEnvFlags({ isCopilotBillingProtocolRequired: true })
it('allows explicitly labeled legacy requests on hosted Sim', async () => {
setEnvFlags({ isHosted: true })
const res = await POST(
request(OLD_GO_HOSTED_VALIDATE_BODY, { 'x-sim-billing-protocol': 'legacy-v0' })
request(SELF_HOSTED_VALIDATE_BODY, { 'x-sim-billing-protocol': 'legacy-v0' })
)

expect(res.status).toBe(200)
Expand Down
Loading
Loading