Skip to content

Commit 37619d7

Browse files
fix: support full endpoint URLs in OpenAI Compatible provider (#5212) (#5214)
Co-authored-by: Matt Rubens <[email protected]>
1 parent 87aa688 commit 37619d7

File tree

2 files changed

+368
-15
lines changed

2 files changed

+368
-15
lines changed

src/services/code-index/embedders/__tests__/openai-compatible.spec.ts

Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,9 @@ import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants"
66
// Mock the OpenAI SDK
77
vitest.mock("openai")
88

9+
// Mock global fetch
10+
global.fetch = vitest.fn()
11+
912
// Mock i18n
1013
vitest.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

Comments
 (0)