@@ -6,6 +6,9 @@ import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants"
66// Mock the OpenAI SDK
77vitest . mock ( "openai" )
88
9+ // Mock global fetch
10+ global . fetch = vitest . fn ( )
11+
912// Mock i18n
1013vitest . mock ( "../../../../i18n" , ( ) => ( {
1114 t : ( key : string , params ?: Record < string , any > ) => {
@@ -613,5 +616,270 @@ describe("OpenAICompatibleEmbedder", () => {
613616 expect ( returnedArray ) . toEqual ( [ 0.25 , 0.5 , 0.75 , 1.0 ] )
614617 } )
615618 } )
619+
620+ /**
621+ * Test Azure OpenAI compatibility with helper functions for conciseness
622+ */
623+ describe ( "Azure OpenAI compatibility" , ( ) => {
624+ const azureUrl =
625+ "https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
626+ const baseUrl = "https://api.openai.com/v1"
627+
628+ // Helper to create mock fetch response
629+ const createMockResponse = ( data : any , status = 200 , ok = true ) => ( {
630+ ok,
631+ status,
632+ json : vitest . fn ( ) . mockResolvedValue ( data ) ,
633+ text : vitest . fn ( ) . mockResolvedValue ( status === 200 ? "" : "Error message" ) ,
634+ } )
635+
636+ // Helper to create base64 embedding
637+ const createBase64Embedding = ( values : number [ ] ) => {
638+ const embedding = new Float32Array ( values )
639+ return Buffer . from ( embedding . buffer ) . toString ( "base64" )
640+ }
641+
642+ // Helper to verify embedding values with floating-point tolerance
643+ const expectEmbeddingValues = ( actual : number [ ] , expected : number [ ] ) => {
644+ expect ( actual ) . toHaveLength ( expected . length )
645+ expected . forEach ( ( val , i ) => expect ( actual [ i ] ) . toBeCloseTo ( val , 5 ) )
646+ }
647+
648+ beforeEach ( ( ) => {
649+ vitest . clearAllMocks ( )
650+ ; ( global . fetch as MockedFunction < typeof fetch > ) . mockReset ( )
651+ } )
652+
653+ describe ( "URL detection" , ( ) => {
654+ it . each ( [
655+ [
656+ "https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01" ,
657+ true ,
658+ ] ,
659+ [ "https://myresource.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings" , true ] ,
660+ [ "https://api.openai.com/v1" , false ] ,
661+ [ "https://api.example.com" , false ] ,
662+ [ "http://localhost:8080" , false ] ,
663+ ] ) ( "should detect URL type correctly: %s -> %s" , ( url , expected ) => {
664+ const embedder = new OpenAICompatibleEmbedder ( url , testApiKey , testModelId )
665+ const isFullUrl = ( embedder as any ) . isFullEndpointUrl ( url )
666+ expect ( isFullUrl ) . toBe ( expected )
667+ } )
668+
669+ // Edge cases where 'embeddings' or 'deployments' appear in non-endpoint contexts
670+ it ( "should return false for URLs with 'embeddings' in non-endpoint contexts" , ( ) => {
671+ const testUrls = [
672+ "https://api.example.com/embeddings-service/v1" ,
673+ "https://embeddings.example.com/api" ,
674+ "https://api.example.com/v1/embeddings-api" ,
675+ "https://my-embeddings-provider.com/v1" ,
676+ ]
677+
678+ testUrls . forEach ( ( url ) => {
679+ const embedder = new OpenAICompatibleEmbedder ( url , testApiKey , testModelId )
680+ const isFullUrl = ( embedder as any ) . isFullEndpointUrl ( url )
681+ expect ( isFullUrl ) . toBe ( false )
682+ } )
683+ } )
684+
685+ it ( "should return false for URLs with 'deployments' in non-endpoint contexts" , ( ) => {
686+ const testUrls = [
687+ "https://deployments.example.com/api" ,
688+ "https://api.deployments.com/v1" ,
689+ "https://my-deployments-service.com/api/v1" ,
690+ "https://deployments-manager.example.com" ,
691+ ]
692+
693+ testUrls . forEach ( ( url ) => {
694+ const embedder = new OpenAICompatibleEmbedder ( url , testApiKey , testModelId )
695+ const isFullUrl = ( embedder as any ) . isFullEndpointUrl ( url )
696+ expect ( isFullUrl ) . toBe ( false )
697+ } )
698+ } )
699+
700+ it ( "should correctly identify actual endpoint URLs" , ( ) => {
701+ const endpointUrls = [
702+ "https://api.example.com/v1/embeddings" ,
703+ "https://api.example.com/v1/embeddings?api-version=2024" ,
704+ "https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings" ,
705+ "https://api.example.com/embed" ,
706+ "https://api.example.com/embed?version=1" ,
707+ ]
708+
709+ endpointUrls . forEach ( ( url ) => {
710+ const embedder = new OpenAICompatibleEmbedder ( url , testApiKey , testModelId )
711+ const isFullUrl = ( embedder as any ) . isFullEndpointUrl ( url )
712+ expect ( isFullUrl ) . toBe ( true )
713+ } )
714+ } )
715+ } )
716+
717+ describe ( "direct HTTP requests" , ( ) => {
718+ it ( "should use direct fetch for Azure URLs and SDK for base URLs" , async ( ) => {
719+ const testTexts = [ "Test text" ]
720+ const base64String = createBase64Embedding ( [ 0.1 , 0.2 , 0.3 ] )
721+
722+ // Test Azure URL (direct fetch)
723+ const azureEmbedder = new OpenAICompatibleEmbedder ( azureUrl , testApiKey , testModelId )
724+ const mockFetchResponse = createMockResponse ( {
725+ data : [ { embedding : base64String } ] ,
726+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
727+ } )
728+ ; ( global . fetch as MockedFunction < typeof fetch > ) . mockResolvedValue ( mockFetchResponse as any )
729+
730+ const azureResult = await azureEmbedder . createEmbeddings ( testTexts )
731+ expect ( global . fetch ) . toHaveBeenCalledWith (
732+ azureUrl ,
733+ expect . objectContaining ( {
734+ method : "POST" ,
735+ headers : expect . objectContaining ( {
736+ "api-key" : testApiKey ,
737+ Authorization : `Bearer ${ testApiKey } ` ,
738+ } ) ,
739+ } ) ,
740+ )
741+ expect ( mockEmbeddingsCreate ) . not . toHaveBeenCalled ( )
742+ expectEmbeddingValues ( azureResult . embeddings [ 0 ] , [ 0.1 , 0.2 , 0.3 ] )
743+
744+ // Reset and test base URL (SDK)
745+ vitest . clearAllMocks ( )
746+ const baseEmbedder = new OpenAICompatibleEmbedder ( baseUrl , testApiKey , testModelId )
747+ mockEmbeddingsCreate . mockResolvedValue ( {
748+ data : [ { embedding : [ 0.4 , 0.5 , 0.6 ] } ] ,
749+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
750+ } )
751+
752+ const baseResult = await baseEmbedder . createEmbeddings ( testTexts )
753+ expect ( mockEmbeddingsCreate ) . toHaveBeenCalled ( )
754+ expect ( global . fetch ) . not . toHaveBeenCalled ( )
755+ expect ( baseResult . embeddings [ 0 ] ) . toEqual ( [ 0.4 , 0.5 , 0.6 ] )
756+ } )
757+
758+ it . each ( [
759+ [ 401 , "Authentication failed. Please check your API key." ] ,
760+ [ 500 , "Failed to create embeddings after 3 attempts" ] ,
761+ ] ) ( "should handle HTTP errors: %d" , async ( status , expectedMessage ) => {
762+ const embedder = new OpenAICompatibleEmbedder ( azureUrl , testApiKey , testModelId )
763+ const mockResponse = createMockResponse ( { } , status , false )
764+ ; ( global . fetch as MockedFunction < typeof fetch > ) . mockResolvedValue ( mockResponse as any )
765+
766+ await expect ( embedder . createEmbeddings ( [ "test" ] ) ) . rejects . toThrow ( expectedMessage )
767+ } )
768+
769+ it ( "should handle rate limiting with retries" , async ( ) => {
770+ vitest . useFakeTimers ( )
771+ const embedder = new OpenAICompatibleEmbedder ( azureUrl , testApiKey , testModelId )
772+ const base64String = createBase64Embedding ( [ 0.1 , 0.2 , 0.3 ] )
773+
774+ ; ( global . fetch as MockedFunction < typeof fetch > )
775+ . mockResolvedValueOnce ( createMockResponse ( { } , 429 , false ) as any )
776+ . mockResolvedValueOnce ( createMockResponse ( { } , 429 , false ) as any )
777+ . mockResolvedValueOnce (
778+ createMockResponse ( {
779+ data : [ { embedding : base64String } ] ,
780+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
781+ } ) as any ,
782+ )
783+
784+ const resultPromise = embedder . createEmbeddings ( [ "test" ] )
785+ await vitest . advanceTimersByTimeAsync ( INITIAL_RETRY_DELAY_MS * 3 )
786+ const result = await resultPromise
787+
788+ expect ( global . fetch ) . toHaveBeenCalledTimes ( 3 )
789+ expect ( console . warn ) . toHaveBeenCalledWith ( expect . stringContaining ( "Rate limit hit" ) )
790+ expectEmbeddingValues ( result . embeddings [ 0 ] , [ 0.1 , 0.2 , 0.3 ] )
791+ vitest . useRealTimers ( )
792+ } )
793+
794+ it ( "should handle multiple embeddings and network errors" , async ( ) => {
795+ const embedder = new OpenAICompatibleEmbedder ( azureUrl , testApiKey , testModelId )
796+
797+ // Test multiple embeddings
798+ const base64_1 = createBase64Embedding ( [ 0.25 , 0.5 ] )
799+ const base64_2 = createBase64Embedding ( [ 0.75 , 1.0 ] )
800+ const mockResponse = createMockResponse ( {
801+ data : [ { embedding : base64_1 } , { embedding : base64_2 } ] ,
802+ usage : { prompt_tokens : 20 , total_tokens : 30 } ,
803+ } )
804+ ; ( global . fetch as MockedFunction < typeof fetch > ) . mockResolvedValue ( mockResponse as any )
805+
806+ const result = await embedder . createEmbeddings ( [ "test1" , "test2" ] )
807+ expect ( result . embeddings ) . toHaveLength ( 2 )
808+ expectEmbeddingValues ( result . embeddings [ 0 ] , [ 0.25 , 0.5 ] )
809+ expectEmbeddingValues ( result . embeddings [ 1 ] , [ 0.75 , 1.0 ] )
810+
811+ // Test network error
812+ const networkError = new Error ( "Network failed" )
813+ ; ( global . fetch as MockedFunction < typeof fetch > ) . mockRejectedValue ( networkError )
814+ await expect ( embedder . createEmbeddings ( [ "test" ] ) ) . rejects . toThrow (
815+ "Failed to create embeddings after 3 attempts" ,
816+ )
817+ } )
818+ } )
819+ } )
820+ } )
821+
822+ describe ( "URL detection" , ( ) => {
823+ it ( "should detect Azure deployment URLs as full endpoints" , async ( ) => {
824+ const embedder = new OpenAICompatibleEmbedder (
825+ "https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15" ,
826+ "test-key" ,
827+ )
828+
829+ // The private method is tested indirectly through the createEmbeddings behavior
830+ // If it's detected as a full URL, it will make a direct HTTP request
831+ const mockFetch = vitest . fn ( ) . mockResolvedValue ( {
832+ ok : true ,
833+ json : async ( ) => ( {
834+ data : [ { embedding : [ 0.1 , 0.2 ] } ] ,
835+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
836+ } ) ,
837+ } )
838+ global . fetch = mockFetch
839+
840+ await embedder . createEmbeddings ( [ "test" ] )
841+
842+ // Should make direct HTTP request to the full URL
843+ expect ( mockFetch ) . toHaveBeenCalledWith (
844+ "https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15" ,
845+ expect . any ( Object ) ,
846+ )
847+ } )
848+
849+ it ( "should detect /embed endpoints as full URLs" , async ( ) => {
850+ const embedder = new OpenAICompatibleEmbedder ( "https://api.example.com/v1/embed" , "test-key" )
851+
852+ const mockFetch = vitest . fn ( ) . mockResolvedValue ( {
853+ ok : true ,
854+ json : async ( ) => ( {
855+ data : [ { embedding : [ 0.1 , 0.2 ] } ] ,
856+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
857+ } ) ,
858+ } )
859+ global . fetch = mockFetch
860+
861+ await embedder . createEmbeddings ( [ "test" ] )
862+
863+ // Should make direct HTTP request to the full URL
864+ expect ( mockFetch ) . toHaveBeenCalledWith ( "https://api.example.com/v1/embed" , expect . any ( Object ) )
865+ } )
866+
867+ it ( "should treat base URLs without endpoint patterns as SDK URLs" , async ( ) => {
868+ const embedder = new OpenAICompatibleEmbedder ( "https://api.openai.com/v1" , "test-key" )
869+
870+ // Mock the OpenAI SDK's embeddings.create method
871+ const mockCreate = vitest . fn ( ) . mockResolvedValue ( {
872+ data : [ { embedding : [ 0.1 , 0.2 ] } ] ,
873+ usage : { prompt_tokens : 10 , total_tokens : 15 } ,
874+ } )
875+ embedder [ "embeddingsClient" ] . embeddings = {
876+ create : mockCreate ,
877+ } as any
878+
879+ await embedder . createEmbeddings ( [ "test" ] )
880+
881+ // Should use SDK which will append /embeddings
882+ expect ( mockCreate ) . toHaveBeenCalled ( )
883+ } )
616884 } )
617885} )
0 commit comments