mirror of
https://github.com/RooVetGit/Roo-Code.git
synced 2026-09-22 00:31:46 +00:00
Fix critical issues where the OpenAI Compatible Embedder would hang indefinitely on unresponsive servers and not retry 5xx server errors. Changes: - Add 60s timeout to OpenAI SDK constructor (timeout: 60000, maxRetries: 0) - Add AbortController with 60s timeout to makeDirectEmbeddingRequest() - Convert AbortError to HTTP 504 (Gateway Timeout) - Extend retry logic to handle 5xx errors (500-599) with exponential backoff - Update validation error messages: - 429 -> rateLimitExceeded - 502 -> badGateway (new) - 503 -> serviceUnavailable - 504 -> gatewayTimeout (new) - Other 5xx -> serverError (was configurationError) - Add i18n translations for new error messages in 17 languages - Add unit tests for timeout handling and 5xx retry (5 new tests) - Add unit tests for getErrorMessageForStatus (10 new tests) Impact: - All OpenAI-compatible embedders benefit (Gemini, Mistral, VercelAiGateway, OpenRouter) - No breaking changes - existing functionality preserved - Prevents infinite waits on unresponsive embedding servers - Clear error messages for 502/503/504 errors Files changed: 21 - 2 source files (openai-compatible.ts, validation-helpers.ts) - 17 i18n locale files (en, ru, de, es, fr, hi, id, it, ja, ko, nl, pl, pt-BR, tr, vi, zh-CN, zh-TW) - 3 test files (openai-compatible.spec.ts, openai.spec.ts, validation-helpers.spec.ts) Test results: - code-index tests: 482 passed, 0 failed - All project tests: 8210 total (8154 passed, 57 skipped, 0 failed) - Test files: 582 (569 run, 13 skipped) - Duration: 4m44s
1238 lines
41 KiB
TypeScript
1238 lines
41 KiB
TypeScript
import type { MockedClass, MockedFunction } from "vitest"
|
|
import { OpenAI } from "openai"
|
|
import { OpenAICompatibleEmbedder } from "../openai-compatible"
|
|
import { MAX_ITEM_TOKENS, INITIAL_RETRY_DELAY_MS } from "../../constants"
|
|
|
|
// Mock the OpenAI SDK
|
|
vitest.mock("openai")
|
|
|
|
// Mock global fetch
|
|
global.fetch = vitest.fn()
|
|
|
|
// Mock TelemetryService
|
|
vitest.mock("@roo-code/telemetry", () => ({
|
|
TelemetryService: {
|
|
instance: {
|
|
captureEvent: vitest.fn(),
|
|
},
|
|
},
|
|
}))
|
|
|
|
// Mock i18n
|
|
vitest.mock("../../../../i18n", () => ({
|
|
t: (key: string, params?: Record<string, any>) => {
|
|
const translations: Record<string, string> = {
|
|
"embeddings:authenticationFailed":
|
|
"Failed to create embeddings: Authentication failed. Please check your API key.",
|
|
"embeddings:failedWithStatus": `Failed to create embeddings after ${params?.attempts} attempts: HTTP ${params?.statusCode} - ${params?.errorMessage}`,
|
|
"embeddings:failedWithError": `Failed to create embeddings after ${params?.attempts} attempts: ${params?.errorMessage}`,
|
|
"embeddings:failedMaxAttempts": `Failed to create embeddings after ${params?.attempts} attempts`,
|
|
"embeddings:textExceedsTokenLimit": `Text at index ${params?.index} exceeds maximum token limit (${params?.itemTokens} > ${params?.maxTokens}). Skipping.`,
|
|
"embeddings:rateLimitRetry": `Rate limit hit, retrying in ${params?.delayMs}ms (attempt ${params?.attempt}/${params?.maxRetries})`,
|
|
"embeddings:unknownError": "Unknown error",
|
|
"common:errors.api.invalidKeyInvalidChars":
|
|
"API key contains invalid characters. Please check your API key for special characters.",
|
|
}
|
|
return translations[key] || key
|
|
},
|
|
}))
|
|
|
|
// Mock i18n/setup module used by the error handler
|
|
vitest.mock("../../../../i18n/setup", () => ({
|
|
default: {
|
|
t: (key: string) => {
|
|
const translations: Record<string, string> = {
|
|
"common:errors.api.invalidKeyInvalidChars":
|
|
"API key contains invalid characters. Please check your API key for special characters.",
|
|
}
|
|
return translations[key] || key
|
|
},
|
|
},
|
|
}))
|
|
|
|
const MockedOpenAI = OpenAI as MockedClass<typeof OpenAI>
|
|
|
|
describe("OpenAICompatibleEmbedder", () => {
|
|
let embedder: OpenAICompatibleEmbedder
|
|
let mockOpenAIInstance: any
|
|
let mockEmbeddingsCreate: MockedFunction<any>
|
|
|
|
const testBaseUrl = "https://api.example.com/v1"
|
|
const testApiKey = "test-api-key"
|
|
const testModelId = "text-embedding-3-small"
|
|
|
|
beforeEach(() => {
|
|
vitest.clearAllMocks()
|
|
vitest.spyOn(console, "warn").mockImplementation(() => {})
|
|
vitest.spyOn(console, "error").mockImplementation(() => {})
|
|
|
|
// Setup mock OpenAI instance
|
|
mockEmbeddingsCreate = vitest.fn()
|
|
mockOpenAIInstance = {
|
|
embeddings: {
|
|
create: mockEmbeddingsCreate,
|
|
},
|
|
}
|
|
|
|
MockedOpenAI.mockImplementation(() => mockOpenAIInstance)
|
|
|
|
// Reset global rate limit state to prevent interference between tests
|
|
const tempEmbedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
;(tempEmbedder as any).constructor.globalRateLimitState = {
|
|
isRateLimited: false,
|
|
rateLimitResetTime: 0,
|
|
consecutiveRateLimitErrors: 0,
|
|
lastRateLimitError: 0,
|
|
mutex: (tempEmbedder as any).constructor.globalRateLimitState.mutex,
|
|
}
|
|
})
|
|
|
|
afterEach(() => {
|
|
vitest.restoreAllMocks()
|
|
})
|
|
|
|
describe("constructor", () => {
|
|
it("should create embedder with valid configuration", () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
expect(MockedOpenAI).toHaveBeenCalledWith({
|
|
baseURL: testBaseUrl,
|
|
apiKey: testApiKey,
|
|
timeout: 60000,
|
|
maxRetries: 0,
|
|
})
|
|
expect(embedder).toBeDefined()
|
|
})
|
|
|
|
it("should use default model when modelId is not provided", () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey)
|
|
|
|
expect(MockedOpenAI).toHaveBeenCalledWith({
|
|
baseURL: testBaseUrl,
|
|
apiKey: testApiKey,
|
|
timeout: 60000,
|
|
maxRetries: 0,
|
|
})
|
|
expect(embedder).toBeDefined()
|
|
})
|
|
|
|
it("should throw error when baseUrl is missing", () => {
|
|
expect(() => new OpenAICompatibleEmbedder("", testApiKey, testModelId)).toThrow(
|
|
"embeddings:validation.baseUrlRequired",
|
|
)
|
|
})
|
|
|
|
it("should throw error when apiKey is missing", () => {
|
|
expect(() => new OpenAICompatibleEmbedder(testBaseUrl, "", testModelId)).toThrow(
|
|
"embeddings:validation.apiKeyRequired",
|
|
)
|
|
})
|
|
|
|
it("should throw error when both baseUrl and apiKey are missing", () => {
|
|
expect(() => new OpenAICompatibleEmbedder("", "", testModelId)).toThrow(
|
|
"embeddings:validation.baseUrlRequired",
|
|
)
|
|
})
|
|
|
|
it("should handle API key with invalid characters (ByteString conversion error)", () => {
|
|
// API key with special characters that cause ByteString conversion error
|
|
const invalidApiKey = "sk-test•invalid" // Contains bullet character (U+2022)
|
|
|
|
// Mock the OpenAI constructor to throw ByteString error
|
|
MockedOpenAI.mockImplementationOnce(() => {
|
|
throw new Error(
|
|
"Cannot convert argument to a ByteString because the character at index 7 has a value of 8226 which is greater than 255.",
|
|
)
|
|
})
|
|
|
|
expect(() => new OpenAICompatibleEmbedder(testBaseUrl, invalidApiKey, testModelId)).toThrow(
|
|
"API key contains invalid characters",
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("embedderInfo", () => {
|
|
beforeEach(() => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
})
|
|
|
|
it("should return correct embedder info", () => {
|
|
const info = embedder.embedderInfo
|
|
|
|
expect(info).toEqual({
|
|
name: "openai-compatible",
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("createEmbeddings", () => {
|
|
beforeEach(() => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
})
|
|
|
|
it("should create embeddings for single text", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
|
|
input: testTexts,
|
|
model: testModelId,
|
|
encoding_format: "base64",
|
|
})
|
|
expect(result).toEqual({
|
|
embeddings: [[0.1, 0.2, 0.3]],
|
|
usage: { promptTokens: 10, totalTokens: 15 },
|
|
})
|
|
})
|
|
|
|
it("should create embeddings for multiple texts", async () => {
|
|
const testTexts = ["Hello world", "Goodbye world"]
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
|
|
usage: { prompt_tokens: 20, total_tokens: 30 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
|
|
input: testTexts,
|
|
model: testModelId,
|
|
encoding_format: "base64",
|
|
})
|
|
expect(result).toEqual({
|
|
embeddings: [
|
|
[0.1, 0.2, 0.3],
|
|
[0.4, 0.5, 0.6],
|
|
],
|
|
usage: { promptTokens: 20, totalTokens: 30 },
|
|
})
|
|
})
|
|
|
|
it("should use custom model when provided", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const customModel = "custom-embedding-model"
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
await embedder.createEmbeddings(testTexts, customModel)
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
|
|
input: testTexts,
|
|
model: customModel,
|
|
encoding_format: "base64",
|
|
})
|
|
})
|
|
|
|
it("should handle missing usage data gracefully", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
usage: undefined,
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(result).toEqual({
|
|
embeddings: [[0.1, 0.2, 0.3]],
|
|
usage: { promptTokens: 0, totalTokens: 0 },
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test base64 conversion logic
|
|
*/
|
|
describe("base64 conversion", () => {
|
|
it("should convert base64 encoded embeddings to float arrays", async () => {
|
|
const testTexts = ["Hello world"]
|
|
|
|
// Create a Float32Array with test values that can be exactly represented in Float32
|
|
const testEmbedding = new Float32Array([0.25, 0.5, 0.75, 1.0])
|
|
|
|
// Convert to base64 string (simulating what OpenAI API returns)
|
|
const buffer = Buffer.from(testEmbedding.buffer)
|
|
const base64String = buffer.toString("base64")
|
|
|
|
const mockResponse = {
|
|
data: [{ embedding: base64String }], // Base64 string instead of array
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
|
|
input: testTexts,
|
|
model: testModelId,
|
|
encoding_format: "base64",
|
|
})
|
|
|
|
// Verify the base64 string was converted back to the original float array
|
|
expect(result).toEqual({
|
|
embeddings: [[0.25, 0.5, 0.75, 1.0]],
|
|
usage: { promptTokens: 10, totalTokens: 15 },
|
|
})
|
|
})
|
|
|
|
it("should handle multiple base64 encoded embeddings", async () => {
|
|
const testTexts = ["Hello world", "Goodbye world"]
|
|
|
|
// Create test embeddings with values that can be exactly represented in Float32
|
|
const embedding1 = new Float32Array([0.25, 0.5, 0.75])
|
|
const embedding2 = new Float32Array([1.0, 1.25, 1.5])
|
|
|
|
// Convert to base64 strings
|
|
const base64String1 = Buffer.from(embedding1.buffer).toString("base64")
|
|
const base64String2 = Buffer.from(embedding2.buffer).toString("base64")
|
|
|
|
const mockResponse = {
|
|
data: [{ embedding: base64String1 }, { embedding: base64String2 }],
|
|
usage: { prompt_tokens: 20, total_tokens: 30 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(result).toEqual({
|
|
embeddings: [
|
|
[0.25, 0.5, 0.75],
|
|
[1.0, 1.25, 1.5],
|
|
],
|
|
usage: { promptTokens: 20, totalTokens: 30 },
|
|
})
|
|
})
|
|
|
|
it("should handle mixed base64 and array embeddings", async () => {
|
|
const testTexts = ["Hello world", "Goodbye world"]
|
|
|
|
// Create one base64 embedding and one regular array (edge case)
|
|
const embedding1 = new Float32Array([0.25, 0.5, 0.75])
|
|
const base64String1 = Buffer.from(embedding1.buffer).toString("base64")
|
|
|
|
const mockResponse = {
|
|
data: [
|
|
{ embedding: base64String1 }, // Base64 string
|
|
{ embedding: [1.0, 1.25, 1.5] }, // Regular array
|
|
],
|
|
usage: { prompt_tokens: 20, total_tokens: 30 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(result).toEqual({
|
|
embeddings: [
|
|
[0.25, 0.5, 0.75],
|
|
[1.0, 1.25, 1.5],
|
|
],
|
|
usage: { promptTokens: 20, totalTokens: 30 },
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test batching logic when texts exceed token limits
|
|
*/
|
|
describe("batching logic", () => {
|
|
it("should process texts in batches", async () => {
|
|
// Use normal sized texts that won't be skipped
|
|
const testTexts = ["text1", "text2", "text3"]
|
|
|
|
mockEmbeddingsCreate.mockResolvedValue({
|
|
data: [
|
|
{ embedding: [0.1, 0.2, 0.3] },
|
|
{ embedding: [0.4, 0.5, 0.6] },
|
|
{ embedding: [0.7, 0.8, 0.9] },
|
|
],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
|
|
await embedder.createEmbeddings(testTexts)
|
|
|
|
// Should be called once for normal texts
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it("should skip texts that exceed MAX_ITEM_TOKENS", async () => {
|
|
const normalText = "Hello world"
|
|
const oversizedText = "a".repeat(MAX_ITEM_TOKENS * 5) // Exceeds MAX_ITEM_TOKENS
|
|
const testTexts = [normalText, oversizedText, normalText]
|
|
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
await embedder.createEmbeddings(testTexts)
|
|
|
|
// Should warn about oversized text
|
|
expect(console.warn).toHaveBeenCalledWith(expect.stringContaining("exceeds maximum token limit"))
|
|
|
|
// Should only process normal texts (1 call for 2 normal texts batched together)
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
|
|
})
|
|
|
|
it("should return correct usage statistics", async () => {
|
|
const testTexts = ["text1", "text2"]
|
|
|
|
mockEmbeddingsCreate.mockResolvedValue({
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }, { embedding: [0.4, 0.5, 0.6] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(result.usage).toEqual({
|
|
promptTokens: 10,
|
|
totalTokens: 15,
|
|
})
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test retry logic with exponential backoff
|
|
*/
|
|
describe("retry logic", () => {
|
|
beforeEach(() => {
|
|
vitest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vitest.clearAllTimers()
|
|
vitest.useRealTimers()
|
|
})
|
|
|
|
it("should retry on rate limit errors with exponential backoff", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const rateLimitError = { status: 429, message: "Rate limit exceeded" }
|
|
|
|
// Create base64 encoded embedding for successful response
|
|
const testEmbedding = new Float32Array([0.25, 0.5, 0.75])
|
|
const base64String = Buffer.from(testEmbedding.buffer).toString("base64")
|
|
|
|
mockEmbeddingsCreate
|
|
.mockRejectedValueOnce(rateLimitError)
|
|
.mockRejectedValueOnce(rateLimitError)
|
|
.mockResolvedValueOnce({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
|
|
const resultPromise = embedder.createEmbeddings(testTexts)
|
|
|
|
// First attempt fails immediately, triggering global rate limit (5s)
|
|
await vitest.advanceTimersByTimeAsync(100)
|
|
|
|
// Wait for global rate limit delay
|
|
await vitest.advanceTimersByTimeAsync(5000)
|
|
|
|
// Second attempt also fails, increasing delay
|
|
await vitest.advanceTimersByTimeAsync(100)
|
|
|
|
// Wait for increased global rate limit delay (10s)
|
|
await vitest.advanceTimersByTimeAsync(10000)
|
|
|
|
const result = await resultPromise
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3)
|
|
expect(console.warn).toHaveBeenCalledWith("embeddings:serverErrorRetry")
|
|
expect(result).toEqual({
|
|
embeddings: [[0.25, 0.5, 0.75]],
|
|
usage: { promptTokens: 10, totalTokens: 15 },
|
|
})
|
|
})
|
|
|
|
it("should not retry on non-rate-limit errors", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const authError = new Error("Unauthorized")
|
|
;(authError as any).status = 401
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(authError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings: Authentication failed. Please check your API key.",
|
|
)
|
|
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(1)
|
|
expect(console.warn).not.toHaveBeenCalledWith(expect.stringContaining("Rate limit hit"))
|
|
})
|
|
|
|
it("should retry on 5xx server errors", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const serverError = new Error("Internal server error")
|
|
;(serverError as any).status = 500
|
|
|
|
// Setup 3 rejections for 3 attempts (MAX_RETRIES = 3)
|
|
mockEmbeddingsCreate
|
|
.mockRejectedValueOnce(serverError)
|
|
.mockRejectedValueOnce(serverError)
|
|
.mockRejectedValueOnce(serverError)
|
|
|
|
const resultPromise = embedder.createEmbeddings(testTexts)
|
|
|
|
// Register the rejection handler BEFORE advancing timers
|
|
// This prevents unhandledRejection because the error handler is attached
|
|
// before the promise actually rejects during timer advancement
|
|
const resultExpectThrow = expect(resultPromise).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: HTTP 500 - Internal server error",
|
|
)
|
|
|
|
// Run all timers - this triggers the retries and rejections
|
|
// The rejection handler is already registered, so no unhandledRejection
|
|
await vitest.runAllTimersAsync()
|
|
|
|
// Wait for the assertion to complete
|
|
await resultExpectThrow
|
|
|
|
// Verify all 3 attempts were made (retry для 5xx)
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledTimes(3)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test error handling scenarios
|
|
*/
|
|
describe("error handling", () => {
|
|
it("should handle API errors gracefully", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const apiError = new Error("API connection failed")
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(apiError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: API connection failed",
|
|
)
|
|
|
|
expect(console.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("OpenAI Compatible embedder error"),
|
|
apiError,
|
|
)
|
|
})
|
|
|
|
it("should handle batch processing errors", async () => {
|
|
const testTexts = ["text1", "text2"]
|
|
const batchError = new Error("Batch processing failed")
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(batchError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: Batch processing failed",
|
|
)
|
|
|
|
expect(console.error).toHaveBeenCalledWith(
|
|
expect.stringContaining("OpenAI Compatible embedder error"),
|
|
expect.any(Error),
|
|
)
|
|
})
|
|
|
|
it("should handle empty text arrays", async () => {
|
|
const testTexts: string[] = []
|
|
|
|
const result = await embedder.createEmbeddings(testTexts)
|
|
|
|
expect(result).toEqual({
|
|
embeddings: [],
|
|
usage: { promptTokens: 0, totalTokens: 0 },
|
|
})
|
|
expect(mockEmbeddingsCreate).not.toHaveBeenCalled()
|
|
})
|
|
|
|
it("should handle malformed API responses", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const malformedResponse = {
|
|
data: null,
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}
|
|
|
|
mockEmbeddingsCreate.mockResolvedValue(malformedResponse)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow()
|
|
})
|
|
|
|
it("should provide specific authentication error message", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const authError = new Error("Invalid API key")
|
|
;(authError as any).status = 401
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(authError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings: Authentication failed. Please check your API key.",
|
|
)
|
|
})
|
|
|
|
it("should provide detailed error message for HTTP errors", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const httpError = new Error("Bad request")
|
|
;(httpError as any).status = 400
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(httpError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: HTTP 400 - Bad request",
|
|
)
|
|
})
|
|
|
|
it("should handle errors without status codes", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const networkError = new Error("Network timeout")
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(networkError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: Network timeout",
|
|
)
|
|
})
|
|
|
|
it("should handle errors without message property", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const weirdError = { toString: () => "Custom error object" }
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(weirdError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: Custom error object",
|
|
)
|
|
})
|
|
|
|
it("should handle completely unknown error types", async () => {
|
|
const testTexts = ["Hello world"]
|
|
const unknownError = null
|
|
|
|
mockEmbeddingsCreate.mockRejectedValue(unknownError)
|
|
|
|
await expect(embedder.createEmbeddings(testTexts)).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts: Unknown error",
|
|
)
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test to confirm OpenAI package bug with base64 encoding
|
|
* This test verifies that when we request encoding_format: "base64",
|
|
* the OpenAI package returns unparsed base64 strings as expected.
|
|
* This is the behavior we rely on in our workaround.
|
|
*/
|
|
describe("OpenAI package base64 behavior verification", () => {
|
|
it("should return unparsed base64 when encoding_format is base64", async () => {
|
|
const testTexts = ["Hello world"]
|
|
|
|
// Create a real OpenAI instance to test the actual package behavior
|
|
const realOpenAI = new ((await vi.importActual("openai")) as any).OpenAI({
|
|
baseURL: testBaseUrl,
|
|
apiKey: testApiKey,
|
|
})
|
|
|
|
// Create test embedding data as base64 using values that can be exactly represented in Float32
|
|
const testEmbedding = new Float32Array([0.25, 0.5, 0.75, 1.0])
|
|
const buffer = Buffer.from(testEmbedding.buffer)
|
|
const base64String = buffer.toString("base64")
|
|
|
|
// Mock the raw API response that would come from OpenAI
|
|
const mockApiResponse = {
|
|
data: [
|
|
{
|
|
object: "embedding",
|
|
embedding: base64String, // Raw base64 string from API
|
|
index: 0,
|
|
},
|
|
],
|
|
model: "text-embedding-3-small",
|
|
object: "list",
|
|
usage: {
|
|
prompt_tokens: 2,
|
|
total_tokens: 2,
|
|
},
|
|
}
|
|
|
|
// Mock the methodRequest method which is called by post()
|
|
const mockMethodRequest = vi.fn()
|
|
const mockAPIPromise = {
|
|
then: vi.fn().mockImplementation((callback) => {
|
|
return Promise.resolve(callback(mockApiResponse))
|
|
}),
|
|
catch: vi.fn(),
|
|
finally: vi.fn(),
|
|
}
|
|
mockMethodRequest.mockReturnValue(mockAPIPromise)
|
|
|
|
// Replace the methodRequest method on the client
|
|
;(realOpenAI as any).post = vi.fn().mockImplementation((path, opts) => {
|
|
return mockMethodRequest("post", path, opts)
|
|
})
|
|
|
|
// Call the embeddings.create method with base64 encoding
|
|
const response = await realOpenAI.embeddings.create({
|
|
input: testTexts,
|
|
model: "text-embedding-3-small",
|
|
encoding_format: "base64",
|
|
})
|
|
|
|
// Verify that the response contains the raw base64 string
|
|
// This confirms the OpenAI package doesn't parse base64 when explicitly requested
|
|
expect(response.data[0].embedding).toBe(base64String)
|
|
expect(typeof response.data[0].embedding).toBe("string")
|
|
|
|
// Verify we can manually convert it back to the original float array
|
|
const returnedBuffer = Buffer.from(response.data[0].embedding as string, "base64")
|
|
const returnedFloat32Array = new Float32Array(
|
|
returnedBuffer.buffer,
|
|
returnedBuffer.byteOffset,
|
|
returnedBuffer.byteLength / 4,
|
|
)
|
|
const returnedArray = Array.from(returnedFloat32Array)
|
|
|
|
expect(returnedArray).toEqual([0.25, 0.5, 0.75, 1.0])
|
|
})
|
|
})
|
|
|
|
/**
|
|
* Test Azure OpenAI compatibility with helper functions for conciseness
|
|
*/
|
|
describe("Azure OpenAI compatibility", () => {
|
|
const azureUrl =
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
|
|
const baseUrl = "https://api.openai.com/v1"
|
|
|
|
// Helper to create mock fetch response
|
|
const createMockResponse = (data: any, status = 200, ok = true) => ({
|
|
ok,
|
|
status,
|
|
json: vitest.fn().mockResolvedValue(data),
|
|
text: vitest.fn().mockResolvedValue(status === 200 ? "" : "Error message"),
|
|
})
|
|
|
|
// Helper to create base64 embedding
|
|
const createBase64Embedding = (values: number[]) => {
|
|
const embedding = new Float32Array(values)
|
|
return Buffer.from(embedding.buffer).toString("base64")
|
|
}
|
|
|
|
// Helper to verify embedding values with floating-point tolerance
|
|
const expectEmbeddingValues = (actual: number[], expected: number[]) => {
|
|
expect(actual).toHaveLength(expected.length)
|
|
expected.forEach((val, i) => expect(actual[i]).toBeCloseTo(val, 5))
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vitest.clearAllMocks()
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockReset()
|
|
})
|
|
|
|
describe("URL detection", () => {
|
|
it.each([
|
|
[
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01",
|
|
true,
|
|
],
|
|
["https://myresource.openai.azure.com/openai/deployments/text-embedding-ada-002/embeddings", true],
|
|
["https://api.openai.com/v1", false],
|
|
["https://api.example.com", false],
|
|
["http://localhost:8080", false],
|
|
])("should detect URL type correctly: %s -> %s", (url, expected) => {
|
|
const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId)
|
|
const isFullUrl = (embedder as any).isFullEndpointUrl(url)
|
|
expect(isFullUrl).toBe(expected)
|
|
})
|
|
|
|
// Edge cases where 'embeddings' or 'deployments' appear in non-endpoint contexts
|
|
it("should return false for URLs with 'embeddings' in non-endpoint contexts", () => {
|
|
const testUrls = [
|
|
"https://api.example.com/embeddings-service/v1",
|
|
"https://embeddings.example.com/api",
|
|
"https://api.example.com/v1/embeddings-api",
|
|
"https://my-embeddings-provider.com/v1",
|
|
]
|
|
|
|
testUrls.forEach((url) => {
|
|
const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId)
|
|
const isFullUrl = (embedder as any).isFullEndpointUrl(url)
|
|
expect(isFullUrl).toBe(false)
|
|
})
|
|
})
|
|
|
|
it("should return false for URLs with 'deployments' in non-endpoint contexts", () => {
|
|
const testUrls = [
|
|
"https://deployments.example.com/api",
|
|
"https://api.deployments.com/v1",
|
|
"https://my-deployments-service.com/api/v1",
|
|
"https://deployments-manager.example.com",
|
|
]
|
|
|
|
testUrls.forEach((url) => {
|
|
const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId)
|
|
const isFullUrl = (embedder as any).isFullEndpointUrl(url)
|
|
expect(isFullUrl).toBe(false)
|
|
})
|
|
})
|
|
|
|
it("should correctly identify actual endpoint URLs", () => {
|
|
const endpointUrls = [
|
|
"https://api.example.com/v1/embeddings",
|
|
"https://api.example.com/v1/embeddings?api-version=2024",
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings",
|
|
"https://api.example.com/embed",
|
|
"https://api.example.com/embed?version=1",
|
|
]
|
|
|
|
endpointUrls.forEach((url) => {
|
|
const embedder = new OpenAICompatibleEmbedder(url, testApiKey, testModelId)
|
|
const isFullUrl = (embedder as any).isFullEndpointUrl(url)
|
|
expect(isFullUrl).toBe(true)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("direct HTTP requests", () => {
|
|
it("should use direct fetch for Azure URLs and SDK for base URLs", async () => {
|
|
const testTexts = ["Test text"]
|
|
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
|
|
|
// Test Azure URL (direct fetch)
|
|
const azureEmbedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
const mockFetchResponse = createMockResponse({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockResolvedValue(mockFetchResponse as any)
|
|
|
|
const azureResult = await azureEmbedder.createEmbeddings(testTexts)
|
|
expect(global.fetch).toHaveBeenCalledWith(
|
|
azureUrl,
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
"api-key": testApiKey,
|
|
Authorization: `Bearer ${testApiKey}`,
|
|
}),
|
|
}),
|
|
)
|
|
expect(mockEmbeddingsCreate).not.toHaveBeenCalled()
|
|
expectEmbeddingValues(azureResult.embeddings[0], [0.1, 0.2, 0.3])
|
|
|
|
// Reset and test base URL (SDK)
|
|
vitest.clearAllMocks()
|
|
const baseEmbedder = new OpenAICompatibleEmbedder(baseUrl, testApiKey, testModelId)
|
|
mockEmbeddingsCreate.mockResolvedValue({
|
|
data: [{ embedding: [0.4, 0.5, 0.6] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
|
|
const baseResult = await baseEmbedder.createEmbeddings(testTexts)
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalled()
|
|
expect(global.fetch).not.toHaveBeenCalled()
|
|
expect(baseResult.embeddings[0]).toEqual([0.4, 0.5, 0.6])
|
|
})
|
|
|
|
it.each([
|
|
[401, "Authentication failed. Please check your API key."],
|
|
[500, "Failed to create embeddings after 3 attempts"],
|
|
])("should handle HTTP errors: %d", async (status, expectedMessage) => {
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
const mockResponse = createMockResponse({}, status, false)
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockResolvedValue(mockResponse as any)
|
|
|
|
await expect(embedder.createEmbeddings(["test"])).rejects.toThrow(expectedMessage)
|
|
})
|
|
|
|
it("should handle rate limiting with retries", async () => {
|
|
vitest.useFakeTimers()
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
const base64String = createBase64Embedding([0.1, 0.2, 0.3])
|
|
|
|
;(global.fetch as MockedFunction<typeof fetch>)
|
|
.mockResolvedValueOnce(createMockResponse({}, 429, false) as any)
|
|
.mockResolvedValueOnce(createMockResponse({}, 429, false) as any)
|
|
.mockResolvedValueOnce(
|
|
createMockResponse({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}) as any,
|
|
)
|
|
|
|
const resultPromise = embedder.createEmbeddings(["test"])
|
|
|
|
// First attempt fails, triggering global rate limit
|
|
await vitest.advanceTimersByTimeAsync(100)
|
|
|
|
// Wait for global rate limit (5s)
|
|
await vitest.advanceTimersByTimeAsync(5000)
|
|
|
|
// Second attempt also fails
|
|
await vitest.advanceTimersByTimeAsync(100)
|
|
|
|
// Wait for increased global rate limit (10s)
|
|
await vitest.advanceTimersByTimeAsync(10000)
|
|
|
|
const result = await resultPromise
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(3)
|
|
// Check that rate limit warnings were logged
|
|
expect(console.warn).toHaveBeenCalledWith("embeddings:serverErrorRetry")
|
|
expectEmbeddingValues(result.embeddings[0], [0.1, 0.2, 0.3])
|
|
vitest.useRealTimers()
|
|
})
|
|
|
|
it("should handle multiple embeddings and network errors", async () => {
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
|
|
// Test multiple embeddings
|
|
const base64_1 = createBase64Embedding([0.25, 0.5])
|
|
const base64_2 = createBase64Embedding([0.75, 1.0])
|
|
const mockResponse = createMockResponse({
|
|
data: [{ embedding: base64_1 }, { embedding: base64_2 }],
|
|
usage: { prompt_tokens: 20, total_tokens: 30 },
|
|
})
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockResolvedValue(mockResponse as any)
|
|
|
|
const result = await embedder.createEmbeddings(["test1", "test2"])
|
|
expect(result.embeddings).toHaveLength(2)
|
|
expectEmbeddingValues(result.embeddings[0], [0.25, 0.5])
|
|
expectEmbeddingValues(result.embeddings[1], [0.75, 1.0])
|
|
|
|
// Test network error
|
|
const networkError = new Error("Network failed")
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockRejectedValue(networkError)
|
|
await expect(embedder.createEmbeddings(["test"])).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts",
|
|
)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("timeout handling", () => {
|
|
it("should pass timeout and maxRetries to OpenAI SDK constructor", () => {
|
|
new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
expect(MockedOpenAI).toHaveBeenCalledWith({
|
|
baseURL: testBaseUrl,
|
|
apiKey: testApiKey,
|
|
timeout: 60000,
|
|
maxRetries: 0,
|
|
})
|
|
})
|
|
|
|
it("should handle AbortError as 504 Gateway Timeout in direct fetch", async () => {
|
|
const azureUrl =
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
|
|
// Mock fetch to simulate timeout (AbortError)
|
|
const abortError = new DOMException("The operation was aborted", "AbortError")
|
|
;(global.fetch as MockedFunction<typeof fetch>).mockRejectedValue(abortError)
|
|
|
|
await expect(embedder.createEmbeddings(["test"])).rejects.toThrow(
|
|
"Failed to create embeddings after 3 attempts",
|
|
)
|
|
})
|
|
})
|
|
|
|
describe("5xx retry handling", () => {
|
|
beforeEach(() => {
|
|
vitest.useFakeTimers()
|
|
})
|
|
|
|
afterEach(() => {
|
|
vitest.useRealTimers()
|
|
})
|
|
|
|
it("should retry on 502 Bad Gateway", async () => {
|
|
const azureUrl =
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
|
|
const base64String = Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64")
|
|
|
|
;(global.fetch as MockedFunction<typeof fetch>)
|
|
.mockResolvedValueOnce({ ok: false, status: 502, text: async () => "Bad Gateway" } as any)
|
|
.mockResolvedValueOnce({ ok: false, status: 502, text: async () => "Bad Gateway" } as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}),
|
|
} as any)
|
|
|
|
const resultPromise = embedder.createEmbeddings(["test"])
|
|
|
|
// Advance timers for retry delays (500ms, 1000ms)
|
|
await vitest.advanceTimersByTimeAsync(500)
|
|
await vitest.advanceTimersByTimeAsync(1000)
|
|
|
|
const result = await resultPromise
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(3)
|
|
expect(console.warn).toHaveBeenCalledWith("embeddings:serverErrorRetry")
|
|
expect(result.embeddings).toHaveLength(1)
|
|
})
|
|
|
|
it("should retry on 503 Service Unavailable", async () => {
|
|
const azureUrl =
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
|
|
const base64String = Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64")
|
|
|
|
;(global.fetch as MockedFunction<typeof fetch>)
|
|
.mockResolvedValueOnce({ ok: false, status: 503, text: async () => "Service Unavailable" } as any)
|
|
.mockResolvedValueOnce({ ok: false, status: 503, text: async () => "Service Unavailable" } as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}),
|
|
} as any)
|
|
|
|
const resultPromise = embedder.createEmbeddings(["test"])
|
|
|
|
await vitest.advanceTimersByTimeAsync(500)
|
|
await vitest.advanceTimersByTimeAsync(1000)
|
|
|
|
const result = await resultPromise
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(3)
|
|
expect(console.warn).toHaveBeenCalledWith("embeddings:serverErrorRetry")
|
|
expect(result.embeddings).toHaveLength(1)
|
|
})
|
|
|
|
it("should retry on 504 Gateway Timeout", async () => {
|
|
const azureUrl =
|
|
"https://myresource.openai.azure.com/openai/deployments/mymodel/embeddings?api-version=2024-02-01"
|
|
const embedder = new OpenAICompatibleEmbedder(azureUrl, testApiKey, testModelId)
|
|
|
|
const base64String = Buffer.from(new Float32Array([0.1, 0.2, 0.3]).buffer).toString("base64")
|
|
|
|
;(global.fetch as MockedFunction<typeof fetch>)
|
|
.mockResolvedValueOnce({ ok: false, status: 504, text: async () => "Gateway Timeout" } as any)
|
|
.mockResolvedValueOnce({ ok: false, status: 504, text: async () => "Gateway Timeout" } as any)
|
|
.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: [{ embedding: base64String }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}),
|
|
} as any)
|
|
|
|
const resultPromise = embedder.createEmbeddings(["test"])
|
|
|
|
await vitest.advanceTimersByTimeAsync(500)
|
|
await vitest.advanceTimersByTimeAsync(1000)
|
|
|
|
const result = await resultPromise
|
|
|
|
expect(global.fetch).toHaveBeenCalledTimes(3)
|
|
expect(console.warn).toHaveBeenCalledWith("embeddings:serverErrorRetry")
|
|
expect(result.embeddings).toHaveLength(1)
|
|
})
|
|
})
|
|
})
|
|
|
|
describe("URL detection", () => {
|
|
it("should detect Azure deployment URLs as full endpoints", async () => {
|
|
const embedder = new OpenAICompatibleEmbedder(
|
|
"https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15",
|
|
"test-key",
|
|
)
|
|
|
|
// The private method is tested indirectly through the createEmbeddings behavior
|
|
// If it's detected as a full URL, it will make a direct HTTP request
|
|
const mockFetch = vitest.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ embedding: [0.1, 0.2] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}),
|
|
})
|
|
global.fetch = mockFetch
|
|
|
|
await embedder.createEmbeddings(["test"])
|
|
|
|
// Should make direct HTTP request to the full URL
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
"https://myinstance.openai.azure.com/openai/deployments/my-deployment/embeddings?api-version=2023-05-15",
|
|
expect.any(Object),
|
|
)
|
|
})
|
|
|
|
it("should detect /embed endpoints as full URLs", async () => {
|
|
const embedder = new OpenAICompatibleEmbedder("https://api.example.com/v1/embed", "test-key")
|
|
|
|
const mockFetch = vitest.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({
|
|
data: [{ embedding: [0.1, 0.2] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
}),
|
|
})
|
|
global.fetch = mockFetch
|
|
|
|
await embedder.createEmbeddings(["test"])
|
|
|
|
// Should make direct HTTP request to the full URL
|
|
expect(mockFetch).toHaveBeenCalledWith("https://api.example.com/v1/embed", expect.any(Object))
|
|
})
|
|
|
|
it("should treat base URLs without endpoint patterns as SDK URLs", async () => {
|
|
const embedder = new OpenAICompatibleEmbedder("https://api.openai.com/v1", "test-key")
|
|
|
|
// Mock the OpenAI SDK's embeddings.create method
|
|
const mockCreate = vitest.fn().mockResolvedValue({
|
|
data: [{ embedding: [0.1, 0.2] }],
|
|
usage: { prompt_tokens: 10, total_tokens: 15 },
|
|
})
|
|
embedder["embeddingsClient"].embeddings = {
|
|
create: mockCreate,
|
|
} as any
|
|
|
|
await embedder.createEmbeddings(["test"])
|
|
|
|
// Should use SDK which will append /embeddings
|
|
expect(mockCreate).toHaveBeenCalled()
|
|
})
|
|
})
|
|
|
|
describe("validateConfiguration", () => {
|
|
let embedder: OpenAICompatibleEmbedder
|
|
let mockFetch: MockedFunction<typeof fetch>
|
|
|
|
beforeEach(() => {
|
|
vitest.clearAllMocks()
|
|
// Reset and re-assign the global fetch mock
|
|
global.fetch = vitest.fn()
|
|
mockFetch = global.fetch as MockedFunction<typeof fetch>
|
|
})
|
|
|
|
it("should validate successfully with valid configuration and base URL", async () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
const mockResponse = {
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
usage: { prompt_tokens: 2, total_tokens: 2 },
|
|
}
|
|
mockEmbeddingsCreate.mockResolvedValue(mockResponse)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(true)
|
|
expect(result.error).toBeUndefined()
|
|
expect(mockEmbeddingsCreate).toHaveBeenCalledWith({
|
|
input: ["test"],
|
|
model: testModelId,
|
|
encoding_format: "base64",
|
|
})
|
|
})
|
|
|
|
it("should validate successfully with full endpoint URL", async () => {
|
|
const fullUrl = "https://api.example.com/v1/embeddings"
|
|
embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId)
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: true,
|
|
status: 200,
|
|
json: async () => ({
|
|
data: [{ embedding: [0.1, 0.2, 0.3] }],
|
|
usage: { prompt_tokens: 2, total_tokens: 2 },
|
|
}),
|
|
text: async () => "",
|
|
} as any)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(true)
|
|
expect(result.error).toBeUndefined()
|
|
expect(mockFetch).toHaveBeenCalledWith(
|
|
fullUrl,
|
|
expect.objectContaining({
|
|
method: "POST",
|
|
headers: expect.objectContaining({
|
|
Authorization: `Bearer ${testApiKey}`,
|
|
}),
|
|
}),
|
|
)
|
|
})
|
|
|
|
it("should fail validation with authentication error", async () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
const authError = new Error("Invalid API key")
|
|
;(authError as any).status = 401
|
|
mockEmbeddingsCreate.mockRejectedValue(authError)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toBe("embeddings:validation.authenticationFailed")
|
|
})
|
|
|
|
it("should fail validation with connection error", async () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
const connectionError = new Error("ECONNREFUSED")
|
|
mockEmbeddingsCreate.mockRejectedValue(connectionError)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toBe("embeddings:validation.connectionFailed")
|
|
})
|
|
|
|
it("should fail validation with invalid endpoint for full URL", async () => {
|
|
const fullUrl = "https://api.example.com/v1/embeddings"
|
|
embedder = new OpenAICompatibleEmbedder(fullUrl, testApiKey, testModelId)
|
|
|
|
mockFetch.mockResolvedValueOnce({
|
|
ok: false,
|
|
status: 404,
|
|
json: async () => ({ error: "Not found" }),
|
|
text: async () => "Not found",
|
|
} as any)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toBe("embeddings:validation.invalidEndpoint")
|
|
})
|
|
|
|
it("should fail validation with rate limit error", async () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
const rateLimitError = new Error("Rate limit exceeded")
|
|
;(rateLimitError as any).status = 429
|
|
mockEmbeddingsCreate.mockRejectedValue(rateLimitError)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toBe("embeddings:validation.rateLimitExceeded")
|
|
})
|
|
|
|
it("should fail validation with generic error", async () => {
|
|
embedder = new OpenAICompatibleEmbedder(testBaseUrl, testApiKey, testModelId)
|
|
|
|
const genericError = new Error("Unknown error")
|
|
;(genericError as any).status = 500
|
|
mockEmbeddingsCreate.mockRejectedValue(genericError)
|
|
|
|
const result = await embedder.validateConfiguration()
|
|
|
|
expect(result.valid).toBe(false)
|
|
expect(result.error).toBe("embeddings:validation.serverError")
|
|
})
|
|
})
|
|
})
|