@@ -3,6 +3,10 @@ import { createSessionPrincipal } from '@sim/testing/factories/principal.factory
33import { jsonResponse } from '@sim/testing/helpers/http'
44import { apiKeyByokMock , apiKeyByokMockFns } from '@sim/testing/mocks/api-key-byok.mock'
55import { authInternalMock , authInternalMockFns } from '@sim/testing/mocks/auth-internal.mock'
6+ import {
7+ billingAttributionMock ,
8+ billingAttributionMockFns ,
9+ } from '@sim/testing/mocks/billing-attribution.mock'
610import { billingUsageLogMock } from '@sim/testing/mocks/billing-usage-log.mock'
711import {
812 executorPrincipalMock ,
@@ -111,7 +115,9 @@ const {
111115 mockExecuteChatFunction,
112116 mockGetInternalToolOperationHandler,
113117 mockExecuteInternalToolOperation,
118+ mockPayerHasPaidAccess,
114119} = vi . hoisted ( ( ) => ( {
120+ mockPayerHasPaidAccess : vi . fn ( ) ,
115121 mockGetToolAsync : vi . fn ( ) ,
116122 mockRateLimiterFns : {
117123 acquireKey : vi . fn ( ) ,
@@ -173,6 +179,12 @@ vi.mock('@/lib/core/rate-limiter/hosted-key', () => ({
173179 getHostedKeyRateLimiter : ( ) => mockRateLimiterFns ,
174180} ) )
175181
182+ vi . mock ( '@/lib/billing/core/chat-access' , ( ) => ( {
183+ payerHasPaidAccess : mockPayerHasPaidAccess ,
184+ } ) )
185+
186+ vi . mock ( '@/lib/billing/core/attribution' , ( ) => billingAttributionMock )
187+
176188vi . mock ( '@/lib/uploads/contexts/workspace/file-manager' , ( ) => workspaceFileManagerMock )
177189
178190vi . mock (
@@ -442,6 +454,7 @@ import { createToolConfig, getTool } from '@/tools/utils'
442454import { getToolAsync } from '@/tools/utils.server'
443455
444456const mockGetBYOKKey = apiKeyByokMockFns . mockGetBYOKKey
457+ const mockResolveWorkspaceBillingPayer = billingAttributionMockFns . mockResolveWorkspaceBillingPayer
445458const mockGenerateInternalToken = authInternalMockFns . mockGenerateInternalToken
446459const mockCreateExecutorPrincipalFromExecutionContext =
447460 executorPrincipalMockFns . mockCreateExecutorPrincipalFromExecutionContext
@@ -6551,6 +6564,153 @@ describe('Hosted Key Injection', () => {
65516564 }
65526565 } )
65536566
6567+ describe ( 'requiresPaidPlan' , ( ) => {
6568+ const PAID_ONLY_TOOL_ID = 'test_paid_only_hosting'
6569+
6570+ beforeEach ( ( ) => {
6571+ setEnvFlags ( { isHosted : true , isBillingEnabled : true } )
6572+ mockRateLimiterFns . acquireKey . mockResolvedValue ( {
6573+ success : true ,
6574+ key : 'platform-fixture-key' ,
6575+ keyIndex : 0 ,
6576+ envVarName : 'TEST_HOSTED_KEY' ,
6577+ } )
6578+ tools [ PAID_ONLY_TOOL_ID ] = {
6579+ id : PAID_ONLY_TOOL_ID ,
6580+ name : 'Paid-only fixture' ,
6581+ description : 'Hosted key reserved for paying payers' ,
6582+ version : '1.0.0' ,
6583+ params : { } ,
6584+ request : { url : 'https://example.com/fixture' , method : 'GET' , headers : ( ) => ( { } ) } ,
6585+ hosting : {
6586+ envKeyPrefix : 'TEST_HOSTED_KEY' ,
6587+ apiKeyParam : 'apiKey' ,
6588+ byokProviderId : 'falai' ,
6589+ requiresPaidPlan : true ,
6590+ pricing : { type : 'per_request' , cost : 0.5 } ,
6591+ rateLimit : { mode : 'per_request' , requestsPerMinute : 100 } ,
6592+ } ,
6593+ transformResponse : async ( ) => ( { success : true , output : { } } ) ,
6594+ }
6595+ } )
6596+
6597+ afterEach ( ( ) => {
6598+ Reflect . deleteProperty ( tools , PAID_ONLY_TOOL_ID )
6599+ resetEnvFlagsMock ( )
6600+ } )
6601+
6602+ async function runPaidOnlyTool ( ) {
6603+ mockValidateUrlWithDNS . mockResolvedValue ( { isValid : true , resolvedIP : '93.184.216.34' } )
6604+ mockSecureFetchWithPinnedIP . mockResolvedValue ( {
6605+ ok : true ,
6606+ status : 200 ,
6607+ statusText : 'OK' ,
6608+ headers : { get : ( ) => null , toRecord : ( ) => ( { } ) } ,
6609+ json : async ( ) => ( { ok : true } ) ,
6610+ text : async ( ) => '{"ok":true}' ,
6611+ } )
6612+ const request = mockSecureFetchWithPinnedIP
6613+ const result = await executeTool (
6614+ PAID_ONLY_TOOL_ID ,
6615+ { } ,
6616+ { executionContext : createToolExecutionContext ( { userId : 'user-1' } ) }
6617+ )
6618+ return { result, request }
6619+ }
6620+
6621+ it ( 'refuses the hosted key to a payer without paid access before acquiring one' , async ( ) => {
6622+ mockGetBYOKKey . mockResolvedValue ( null )
6623+ mockPayerHasPaidAccess . mockResolvedValue ( false )
6624+
6625+ const { result, request } = await runPaidOnlyTool ( )
6626+
6627+ expect ( result . success ) . toBe ( false )
6628+ expect ( result . error ) . toContain ( 'available on paid plans' )
6629+ expect ( mockPayerHasPaidAccess ) . toHaveBeenCalledWith ( TEST_BILLING_ATTRIBUTION , {
6630+ onBalanceUnavailable : 'deny' ,
6631+ } )
6632+ expect ( mockRateLimiterFns . acquireKey ) . not . toHaveBeenCalled ( )
6633+ expect ( request ) . not . toHaveBeenCalled ( )
6634+ } )
6635+
6636+ it ( 'grants the hosted key to a payer with paid access' , async ( ) => {
6637+ mockGetBYOKKey . mockResolvedValue ( null )
6638+ mockPayerHasPaidAccess . mockResolvedValue ( true )
6639+
6640+ const { result, request } = await runPaidOnlyTool ( )
6641+
6642+ expect ( result . success ) . toBe ( true )
6643+ expect ( mockRateLimiterFns . acquireKey ) . toHaveBeenCalledTimes ( 1 )
6644+ expect ( request ) . toHaveBeenCalledTimes ( 1 )
6645+ } )
6646+
6647+ async function runPaidOnlyToolWithoutAttribution ( ) {
6648+ mockGetBYOKKey . mockResolvedValue ( null )
6649+ mockValidateUrlWithDNS . mockResolvedValue ( { isValid : true , resolvedIP : '93.184.216.34' } )
6650+ mockSecureFetchWithPinnedIP . mockResolvedValue ( {
6651+ ok : true ,
6652+ status : 200 ,
6653+ statusText : 'OK' ,
6654+ headers : { get : ( ) => null , toRecord : ( ) => ( { } ) } ,
6655+ json : async ( ) => ( { ok : true } ) ,
6656+ text : async ( ) => '{"ok":true}' ,
6657+ } )
6658+ const result = await executeTool ( PAID_ONLY_TOOL_ID , {
6659+ _context : { workspaceId : 'workspace-without-run' , userId : 'user-1' } ,
6660+ } )
6661+ return { result, request : mockSecureFetchWithPinnedIP }
6662+ }
6663+
6664+ it ( 'judges the workspace payer when the run carries no attribution' , async ( ) => {
6665+ mockResolveWorkspaceBillingPayer . mockResolvedValue ( {
6666+ billedAccountUserId : 'payer-1' ,
6667+ organizationId : 'org-1' ,
6668+ payerSubscription : { plan : 'team' } ,
6669+ } )
6670+ mockPayerHasPaidAccess . mockResolvedValue ( true )
6671+
6672+ const { result, request } = await runPaidOnlyToolWithoutAttribution ( )
6673+
6674+ expect ( result . success ) . toBe ( true )
6675+ expect ( mockResolveWorkspaceBillingPayer ) . toHaveBeenCalledWith ( 'workspace-without-run' , {
6676+ onMissing : 'return-null' ,
6677+ } )
6678+ expect ( mockPayerHasPaidAccess ) . toHaveBeenCalledWith (
6679+ {
6680+ billingEntity : { type : 'organization' , id : 'org-1' } ,
6681+ payerSubscription : { plan : 'team' } ,
6682+ } ,
6683+ { onBalanceUnavailable : 'deny' }
6684+ )
6685+ expect ( request ) . toHaveBeenCalledTimes ( 1 )
6686+ } )
6687+
6688+ it ( 'refuses the hosted key when the workspace has no payer' , async ( ) => {
6689+ mockResolveWorkspaceBillingPayer . mockResolvedValue ( null )
6690+ mockPayerHasPaidAccess . mockResolvedValue ( true )
6691+
6692+ const { result, request } = await runPaidOnlyToolWithoutAttribution ( )
6693+
6694+ expect ( result . success ) . toBe ( false )
6695+ expect ( result . error ) . toContain ( 'available on paid plans' )
6696+ expect ( mockPayerHasPaidAccess ) . not . toHaveBeenCalled ( )
6697+ expect ( mockRateLimiterFns . acquireKey ) . not . toHaveBeenCalled ( )
6698+ expect ( request ) . not . toHaveBeenCalled ( )
6699+ } )
6700+
6701+ it ( "runs a free payer's own workspace key without consulting the plan" , async ( ) => {
6702+ mockGetBYOKKey . mockResolvedValue ( { apiKey : 'workspace-own-key' } )
6703+ mockPayerHasPaidAccess . mockResolvedValue ( false )
6704+
6705+ const { result, request } = await runPaidOnlyTool ( )
6706+
6707+ expect ( result . success ) . toBe ( true )
6708+ expect ( mockPayerHasPaidAccess ) . not . toHaveBeenCalled ( )
6709+ expect ( mockRateLimiterFns . acquireKey ) . not . toHaveBeenCalled ( )
6710+ expect ( request ) . toHaveBeenCalledTimes ( 1 )
6711+ } )
6712+ } )
6713+
65546714 it ( 'should not inject hosted key when tool has no hosting config' , async ( ) => {
65556715 const mockTool = {
65566716 id : 'test_no_hosting' ,
0 commit comments