From 455b1f860456b765874bae26c142a6ad2e687767 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 26 Aug 2026 15:53:51 -0700 Subject: [PATCH 1/2] refactor(billing): retire protocol rollout flags --- .../app/api/billing/update-cost/route.test.ts | 58 +++++++-------- apps/sim/app/api/billing/update-cost/route.ts | 4 +- .../copilot/api-keys/validate/route.test.ts | 52 +++++++------- .../api/copilot/api-keys/validate/route.ts | 14 ++-- apps/sim/lib/api/contracts/subscription.ts | 6 +- .../lib/billing/core/billing-attribution.ts | 13 ++-- .../lib/copilot/request/lifecycle/run.test.ts | 70 +++---------------- apps/sim/lib/copilot/request/lifecycle/run.ts | 27 ++----- .../copilot/request/lifecycle/start.test.ts | 24 +++---- .../lib/copilot/request/lifecycle/start.ts | 10 +-- apps/sim/lib/core/config/env-flags.ts | 14 ---- apps/sim/lib/core/config/env.ts | 4 -- packages/testing/src/mocks/env-flags.mock.ts | 4 -- 13 files changed, 100 insertions(+), 200 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.test.ts b/apps/sim/app/api/billing/update-cost/route.test.ts index cb1abf8f194..cf9686740d4 100644 --- a/apps/sim/app/api/billing/update-cost/route.test.ts +++ b/apps/sim/app/api/billing/update-cost/route.test.ts @@ -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', @@ -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', @@ -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 @@ -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) @@ -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( @@ -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) }) @@ -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) @@ -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) @@ -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) @@ -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', }) @@ -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', @@ -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', @@ -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) @@ -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({ @@ -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()) @@ -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) @@ -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', }) ) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 854daaa725e..670548e28e5 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -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' @@ -163,7 +163,7 @@ async function updateCostInner(req: NextRequest, span: Span): Promise { beforeEach(() => { vi.clearAllMocks() resetDbChainMock() - setEnvFlags({ isCopilotBillingProtocolRequired: false }) + setEnvFlags({ isHosted: false }) mockCheckInternalApiKey.mockReturnValue({ success: true }) queueTableRows(schemaMock.user, [{ id: 'user-1' }]) mockResolveBillingAttribution.mockResolvedValue(ATTRIBUTION) @@ -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({ @@ -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() @@ -251,7 +249,7 @@ 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 }) @@ -259,14 +257,14 @@ describe('POST /api/copilot/api-keys/validate billing protocols', () => { }) 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') @@ -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) diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 0e5c3fb153d..ae9e01f4782 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -32,7 +32,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 { isCopilotBillingProtocolRequired } from '@/lib/core/config/env-flags' +import { isHosted } from '@/lib/core/config/env-flags' import { withRouteHandler } from '@/lib/core/utils/with-route-handler' const logger = createLogger('CopilotApiKeysValidate') @@ -63,12 +63,12 @@ type AdmissionBillingDecision = /** * Resolves admission against the versioned Go callback protocol. * - * Markerless old-Go admission is explicitly legacy-v0. A locally resolvable + * Markerless self-hosted admission is legacy-v0. A locally resolvable * workspace selects its current payer; an absent or opaque workspace preserves - * account billing. Because old Go cannot return admission material, this - * mutable resolution is repeated at callback time. Direct-v1 remains scoped - * only to the authenticated Chat/Copilot key owner's hosted account, and - * attributed-v1 never falls back from its immutable envelope. + * account billing. This mutable resolution is repeated at callback time for + * local self-hosted compatibility. Direct-v1 remains scoped only to the + * authenticated Chat/Copilot key owner's hosted account, and attributed-v1 + * never falls back from its immutable envelope. */ async function resolveAdmissionBillingDecision( req: NextRequest, @@ -117,7 +117,7 @@ async function resolveAdmissionBillingDecision( return invalidBillingProtocolResponse() } - if (protocol === undefined && isCopilotBillingProtocolRequired) { + if (protocol === undefined && isHosted) { return invalidBillingProtocolResponse() } diff --git a/apps/sim/lib/api/contracts/subscription.ts b/apps/sim/lib/api/contracts/subscription.ts index 9f4c3884b47..852baa85cd9 100644 --- a/apps/sim/lib/api/contracts/subscription.ts +++ b/apps/sim/lib/api/contracts/subscription.ts @@ -33,9 +33,9 @@ export const billingUpdateCostBodySchema = z.object({ * Originating workspace, used for org-workspace cost attribution on hosted * Sim. The value remains optional because self-hosted/headless callers may * supply an ID from another deployment or omit it. Modern protocols bind a - * locally known workspace to their immutable envelope. Markerless legacy-v0 - * callbacks re-resolve current workspace payer state because old Go cannot - * return admission material; unknown workspaces remain account-only. + * locally known workspace to their immutable envelope. Markerless local + * self-hosted callbacks re-resolve current workspace payer state; unknown + * workspaces remain account-only. */ workspaceId: z.string().min(1).optional(), }) diff --git a/apps/sim/lib/billing/core/billing-attribution.ts b/apps/sim/lib/billing/core/billing-attribution.ts index a0c3d27521c..d98a0bb26b9 100644 --- a/apps/sim/lib/billing/core/billing-attribution.ts +++ b/apps/sim/lib/billing/core/billing-attribution.ts @@ -669,14 +669,15 @@ export async function resolveBillingAttribution({ } /** - * Resolves markerless old-Go (`legacy-v0`) traffic from the workspace visible - * at this request boundary, falling back to account billing when the workspace - * is absent from this Sim deployment. + * Resolves legacy-v0 traffic from the workspace visible at this request + * boundary, falling back to account billing for markerless local self-hosted + * requests when the workspace is absent from this Sim deployment. * * Unlike modern attributed-v1/direct-v1 envelopes, this decision is mutable: - * old Go allocates its callback billing ID after admission and returns no payer - * material, so admission and callback must independently resolve current state. - * Keep this compatibility semantic confined to markerless legacy-v0 paths. + * historical markerless Go allocated its callback billing ID after admission + * and returned no payer material, so admission and callback independently + * resolve current state. Hosted traffic may use this only for explicit + * legacy-v0 replay; markerless use is confined to local self-hosting. */ export async function resolveLegacyV0BillingAttribution({ actorUserId, diff --git a/apps/sim/lib/copilot/request/lifecycle/run.test.ts b/apps/sim/lib/copilot/request/lifecycle/run.test.ts index c63e883116f..5eacd781290 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.test.ts @@ -158,7 +158,6 @@ describe('runCopilotLifecycle', () => { mockEnv.MSHIP_SYSPROMPT_OVERRIDE = undefined setEnvFlags({ isHosted: false, - isCopilotBillingAttributionV1Enabled: false, isCopilotToolPermissionsEnabled: false, }) mockGetAutoAllowedTools.mockResolvedValue(new Set()) @@ -1376,7 +1375,6 @@ describe('runCopilotLifecycle', () => { payerSubscription: null, } setEnvFlags({ isHosted: true }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) mockEnv.COPILOT_API_KEY = 'sim-agent-key' mockRunStreamLoop.mockImplementationOnce( async ( @@ -1475,66 +1473,20 @@ describe('runCopilotLifecycle', () => { }) }) - it('runs legacy-v0 during Sim-first deployment without guessed billing aliases', async () => { + it('rejects hosted work without immutable billing attribution before egress', async () => { setEnvFlags({ isHosted: true }) - mockEnv.COPILOT_API_KEY = 'sim-agent-key' - - await runCopilotLifecycle( - { message: 'hello', messageId: 'message-1' }, - { - userId: 'user-1', - workspaceId: 'ws-1', - chatId: 'chat-1', - executionId: 'execution-1', - runId: 'run-1', - simRequestId: 'request-1', - billingAttribution: { - actorUserId: 'user-1', - workspaceId: 'ws-1', - billedAccountUserId: 'owner-1', - organizationId: null, - billingEntity: { type: 'user', id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - }, - } - ) - - const headers = mockRunStreamLoop.mock.calls[0]?.[1].headers as Record - expect(headers['x-sim-billing-protocol']).toBe('legacy-v0') - expect(headers['x-sim-billing-request-id']).toBeUndefined() - expect(headers['x-sim-billing-attribution']).toBeUndefined() - }) - it('runs modern hosted work without legacy compatibility storage', async () => { - setEnvFlags({ isHosted: true }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) - - await runCopilotLifecycle( - { message: 'hello', messageId: 'message-1' }, - { - userId: 'user-1', - workspaceId: 'ws-1', - chatId: 'chat-1', - billingAttribution: { - actorUserId: 'user-1', + await expect( + runCopilotLifecycle( + { message: 'hello', messageId: 'message-1' }, + { + userId: 'user-1', workspaceId: 'ws-1', - billedAccountUserId: 'owner-1', - organizationId: null, - billingEntity: { type: 'user', id: 'owner-1' }, - billingPeriod: { - start: '2026-07-01T00:00:00.000Z', - end: '2026-08-01T00:00:00.000Z', - }, - payerSubscription: null, - }, - } - ) - - expect(mockRunStreamLoop).toHaveBeenCalledTimes(1) + chatId: 'chat-1', + } + ) + ).rejects.toThrow('Billing attribution is required for hosted Copilot execution') + expect(mockRunStreamLoop).not.toHaveBeenCalled() }) it('does not emit trusted billing headers for a non-hosted lifecycle', async () => { diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts index 79df0daddb5..7c8a1e0e103 100644 --- a/apps/sim/lib/copilot/request/lifecycle/run.ts +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -18,10 +18,6 @@ import { type CopilotEnvironmentContext, prepareCopilotEnvironmentContext, } from '@/lib/copilot/environment-context' -import { - COPILOT_BILLING_PROTOCOL, - COPILOT_BILLING_PROTOCOL_HEADER, -} from '@/lib/copilot/generated/billing-protocol-v1' import { MothershipStreamV1CompletionStatus, MothershipStreamV1EventType, @@ -66,11 +62,7 @@ import type { SecretMountPolicy } from '@/lib/copilot/secret-mount-policy' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' -import { - isCopilotBillingAttributionV1Enabled, - isCopilotToolPermissionsEnabled, - isHosted, -} from '@/lib/core/config/env-flags' +import { isCopilotToolPermissionsEnabled, isHosted } from '@/lib/core/config/env-flags' import { filterModelSafeWorkspaceFileAttachments } from '@/lib/uploads/contexts/workspace/workspace-file-secret-provenance' import { refuseResolvedSecretProjection } from '@/executor/utils/resolved-secret-projection-refusal' import type { ResolvedSecretTraceRegistry } from '@/executor/utils/resolved-secret-trace-registry' @@ -295,12 +287,7 @@ export async function runCopilotLifecycle( } else { execContext.sandboxProfile = undefined } - const shouldUseHostedBillingProtocol = isHosted && isCopilotBillingAttributionV1Enabled - if ( - shouldUseHostedBillingProtocol && - execContext.workspaceId && - !execContext.billingAttribution - ) { + if (isHosted && (!execContext.workspaceId || !execContext.billingAttribution)) { throw new Error('Billing attribution is required for hosted Copilot execution') } let hostedBillingRequest: AttributedBillingRequestEnvelope | undefined @@ -313,7 +300,7 @@ export async function runCopilotLifecycle( throw new Error('Copilot billing attribution does not match its actor and workspace') } execContext.billingAttribution = billingAttribution - if (shouldUseHostedBillingProtocol) { + if (isHosted) { hostedBillingRequest = createAttributedBillingRequestEnvelope(billingAttribution) } } @@ -481,13 +468,7 @@ function mothershipRequestHeaders( ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), ...getMothershipSourceEnvHeaders(), 'X-Client-Version': SIM_AGENT_VERSION, - ...(hostedBillingRequest - ? hostedBillingRequest.headers - : isHosted && !isCopilotBillingAttributionV1Enabled - ? { - [COPILOT_BILLING_PROTOCOL_HEADER]: COPILOT_BILLING_PROTOCOL.legacy, - } - : {}), + ...(hostedBillingRequest ? hostedBillingRequest.headers : {}), } } diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts index b099f6b4929..ca7ec7b62d4 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.test.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -152,7 +152,6 @@ describe('createSSEStream terminal error handling', () => { vi.clearAllMocks() resetDbChainMock() setEnvFlags({ isHosted: false }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) fetchGo.mockResolvedValue( new Response(JSON.stringify({ title: 'Test title' }), { status: 200, @@ -406,7 +405,6 @@ describe('requestChatTitle billing protocol', () => { vi.clearAllMocks() resetDbChainMock() setEnvFlags({ isHosted: true }) - setEnvFlags({ isCopilotBillingAttributionV1Enabled: true }) fetchGo.mockResolvedValue( new Response(JSON.stringify({ title: 'Billing Protocol' }), { status: 200, @@ -439,18 +437,14 @@ describe('requestChatTitle billing protocol', () => { ) }) - it('sends explicit legacy-v0 during the Sim-first compatibility stage', async () => { - setEnvFlags({ isCopilotBillingAttributionV1Enabled: false }) - - await requestChatTitle({ - message: 'explain billing', - model: 'claude-opus-4.8', - userId: 'user-1', - workspaceId: 'workspace-1', - }) - - const headers = fetchGo.mock.calls[0]?.[1]?.headers as Record - expect(headers['x-sim-billing-protocol']).toBe('legacy-v0') - expect(headers['x-sim-billing-request-id']).toBeUndefined() + it('fails before hosted title egress without a billing workspace', async () => { + await expect( + requestChatTitle({ + message: 'explain billing', + model: 'claude-opus-4.8', + userId: 'user-1', + }) + ).resolves.toBeNull() + expect(fetchGo).not.toHaveBeenCalled() }) }) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts index 0dd5fb92eb9..b3a2dd7d210 100644 --- a/apps/sim/lib/copilot/request/lifecycle/start.ts +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -12,10 +12,6 @@ import { } from '@/lib/billing/core/billing-attribution' import { createRunSegment } from '@/lib/copilot/async-runs/repository' import { chatPubSub } from '@/lib/copilot/chat-status' -import { - COPILOT_BILLING_PROTOCOL, - COPILOT_BILLING_PROTOCOL_HEADER, -} from '@/lib/copilot/generated/billing-protocol-v1' import { MothershipStreamV1EventType, MothershipStreamV1SessionKind, @@ -52,7 +48,7 @@ import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse' import { TraceCollector } from '@/lib/copilot/request/trace' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' import { env } from '@/lib/core/config/env' -import { isCopilotBillingAttributionV1Enabled, isHosted } from '@/lib/core/config/env-flags' +import { isHosted } from '@/lib/core/config/env-flags' export { SSE_RESPONSE_HEADERS } @@ -534,9 +530,7 @@ export async function requestChatTitle(params: { Object.assign(headers, getMothershipSourceEnvHeaders()) try { - if (isHosted && !isCopilotBillingAttributionV1Enabled) { - headers[COPILOT_BILLING_PROTOCOL_HEADER] = COPILOT_BILLING_PROTOCOL.legacy - } else if (isHosted) { + if (isHosted) { if (!userId || !workspaceId) { throw new Error('Title generation requires a billing actor and workspace') } diff --git a/apps/sim/lib/core/config/env-flags.ts b/apps/sim/lib/core/config/env-flags.ts index 6407cea999b..72364dda59d 100644 --- a/apps/sim/lib/core/config/env-flags.ts +++ b/apps/sim/lib/core/config/env-flags.ts @@ -54,20 +54,6 @@ const forceHosted = !isProd && isTruthy(getEnv('NEXT_PUBLIC_FORCE_HOSTED')) export const isHosted = forceHosted || appHostname === 'sim.ai' || appHostname.endsWith('.sim.ai') -/** - * Enables the strict attributed-v1 Sim/Copilot billing protocol after the Go - * consumer has rolled out. Disabled is the Sim-first compatibility stage. - */ -export const isCopilotBillingAttributionV1Enabled = isTruthy( - env.COPILOT_BILLING_ATTRIBUTION_V1_ENABLED -) - -/** - * Rejects markerless old-Go billing traffic after an operator explicitly - * confirms the compatibility window has closed. Off by default. - */ -export const isCopilotBillingProtocolRequired = isTruthy(env.COPILOT_BILLING_PROTOCOL_REQUIRED) - /** * Are the Chat module's surfaces shown. On by default, so a deployment that * already has `COPILOT_API_KEY` keeps Chat without setting anything; the setup diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 827c936ee4d..4d8f54b5dd3 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -135,10 +135,6 @@ export const env = createEnv({ // Copilot COPILOT_API_KEY: z.string().min(1).optional(), // Secret for internal sim agent API authentication - /** Enables attributed-v1 only after compatible Copilot instances are deployed. */ - COPILOT_BILLING_ATTRIBUTION_V1_ENABLED: z.boolean().optional(), - /** Rejects markerless old-Go billing traffic only when explicitly enabled. */ - COPILOT_BILLING_PROTOCOL_REQUIRED: z.boolean().optional(), /** Gates risky copilot tools behind an Allow / Skip prompt. Off by default. */ COPILOT_TOOL_PERMISSIONS_ENABLED: z.boolean().optional(), SIM_AGENT_API_URL: z.string().url().optional(), // URL for internal sim agent API diff --git a/packages/testing/src/mocks/env-flags.mock.ts b/packages/testing/src/mocks/env-flags.mock.ts index 2840cc58f49..c0f3fb6ba51 100644 --- a/packages/testing/src/mocks/env-flags.mock.ts +++ b/packages/testing/src/mocks/env-flags.mock.ts @@ -12,8 +12,6 @@ export interface EnvFlagsMockState { isDev: boolean isTest: boolean isHosted: boolean - isCopilotBillingAttributionV1Enabled: boolean - isCopilotBillingProtocolRequired: boolean isChatEnabled: boolean isStatusNoticePreviewEnabled: boolean isCopilotToolPermissionsEnabled: boolean @@ -62,8 +60,6 @@ const defaultEnvFlagsState: EnvFlagsMockState = { isDev: false, isTest: true, isHosted: false, - isCopilotBillingAttributionV1Enabled: false, - isCopilotBillingProtocolRequired: false, isChatEnabled: true, isStatusNoticePreviewEnabled: false, isCopilotToolPermissionsEnabled: false, From ee385f5746086d9553f5a49c5c1edeef0244d5c6 Mon Sep 17 00:00:00 2001 From: Vikhyath Mondreti Date: Wed, 26 Aug 2026 16:18:06 -0700 Subject: [PATCH 2/2] refactor(copilot): drop unused steering billing metadata --- apps/sim/app/api/billing/update-cost/route.ts | 8 +++----- apps/sim/app/api/copilot/chat/steer/route.ts | 1 - apps/sim/lib/copilot/request/session/steer.ts | 6 ------ 3 files changed, 3 insertions(+), 12 deletions(-) diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 670548e28e5..22623c17500 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -229,11 +229,9 @@ async function updateCostInner(req: NextRequest, span: Span): Promise chatId, steeringId, content, - workspaceId: run?.workspaceId ?? undefined, }) queued = result.queued goStatus = result.status diff --git a/apps/sim/lib/copilot/request/session/steer.ts b/apps/sim/lib/copilot/request/session/steer.ts index 4e63afca65e..a24a603bbcf 100644 --- a/apps/sim/lib/copilot/request/session/steer.ts +++ b/apps/sim/lib/copilot/request/session/steer.ts @@ -1,8 +1,4 @@ import type { Context } from '@opentelemetry/api' -import { - COPILOT_BILLING_PROTOCOL, - COPILOT_BILLING_PROTOCOL_HEADER, -} from '@/lib/billing/core/billing-attribution' import { TraceAttr } from '@/lib/copilot/generated/trace-attributes-v1' import { fetchGo } from '@/lib/copilot/request/go/fetch' import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url' @@ -25,7 +21,6 @@ export async function requestStreamSteering(params: { chatId: string steeringId: string content: string - workspaceId?: string timeoutMs?: number otelContext?: Context }): Promise<{ queued: boolean; status: number }> { @@ -41,7 +36,6 @@ export async function requestStreamSteering(params: { const headers: Record = { 'Content-Type': 'application/json', - [COPILOT_BILLING_PROTOCOL_HEADER]: COPILOT_BILLING_PROTOCOL.legacy, } if (env.COPILOT_API_KEY) { headers['x-api-key'] = env.COPILOT_API_KEY